Skip to content

Commit bd2a668

Browse files
committed
feat(webapp): roles page full-height sticky table + current role + env chips
The roles comparison table now fills the remaining height with a sticky header and scrolls internally. A line under the description states the viewer's own role, and env-tier permission conditions render as environment chips for the environments they apply to instead of raw text.
1 parent c6cb229 commit bd2a668

1 file changed

Lines changed: 50 additions & 6 deletions

File tree

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles

apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type MetaFunction } from "@remix-run/react";
33
import { useState } from "react";
44
import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson";
55
import { z } from "zod";
6+
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
67
import { Feedback } from "~/components/Feedback";
78
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
89
import { Badge } from "~/components/primitives/Badge";
@@ -57,20 +58,21 @@ export const loader = dashboardLoader(
5758
},
5859
authorization: { action: "read", resource: { type: "members" } },
5960
},
60-
async ({ context }) => {
61+
async ({ context, user }) => {
6162
const orgId = context.organizationId;
6263
if (!orgId) {
6364
throw new Response("Not Found", { status: 404 });
6465
}
6566

66-
const [roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin] =
67+
const [roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin, currentRole] =
6768
await Promise.all([
6869
rbac.allRoles(orgId),
6970
rbac.getAssignableRoleIds(orgId),
7071
rbac.allPermissions(orgId),
7172
rbac.systemRoles(orgId),
7273
// OSS self-host has no RBAC plugin.
7374
rbac.isUsingPlugin(),
75+
rbac.getUserRole({ userId: user.id, organizationId: orgId }),
7476
]);
7577

7678
return typedjson({
@@ -79,6 +81,7 @@ export const loader = dashboardLoader(
7981
allPermissions,
8082
systemRoles,
8183
isUsingPlugin,
84+
currentRoleName: currentRole?.name ?? null,
8285
});
8386
}
8487
);
@@ -92,7 +95,7 @@ type RolePermission = LoaderRole["permissions"][number];
9295
const FALLBACK_GROUP = "Other";
9396

9497
export default function Page() {
95-
const { roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin } =
98+
const { roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin, currentRoleName } =
9699
useTypedLoaderData<typeof loader>();
97100
const organization = useOrganization();
98101
const showSelfServe = useShowSelfServe();
@@ -122,19 +125,24 @@ export default function Page() {
122125
{isUsingPlugin && showSelfServe ? <RequestCustomRoles /> : null}
123126
</NavBar>
124127
<PageBody scrollable={false}>
125-
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr]">
128+
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
126129
<div className="border-b border-grid-bright px-4 py-6">
127130
<Paragraph variant="small">
128131
Roles control what each team member can do in <strong>{organization.title}</strong>.
129132
Compare what each role grants below; assign a role to a team member from the{" "}
130133
<TextLink to={`/orgs/${organization.slug}/settings/team`}>Team page</TextLink>.
131134
</Paragraph>
135+
{currentRoleName ? (
136+
<Paragraph variant="small" className="mt-2">
137+
Your role is <strong className="text-text-bright">{currentRoleName}</strong>.
138+
</Paragraph>
139+
) : null}
132140
</div>
133-
<div className="overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
141+
<div className="min-h-0 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
134142
{columns.length === 0 ? (
135143
<EmptyState isUsingPlugin={isUsingPlugin} showSelfServe={showSelfServe} />
136144
) : (
137-
<Table containerClassName="border-t-0">
145+
<Table stickyHeader containerClassName="border-t-0">
138146
<TableHeader>
139147
<TableRow>
140148
<TableHeaderCell>Permission</TableHeaderCell>
@@ -287,6 +295,18 @@ function RoleCell({
287295

288296
const conditionalDeny = denied.find((p) => p.conditions);
289297
if (conditionalDeny?.conditions) {
298+
const allowedEnvTypes = allowedEnvTypesFromDeny(conditionalDeny.conditions);
299+
if (allowedEnvTypes) {
300+
// Conditional grant: show the environments the permission is allowed in.
301+
return (
302+
<div className="flex flex-col items-start gap-1">
303+
{allowedEnvTypes.map((type) => (
304+
<EnvironmentCombo key={type} environment={{ type }} className="text-xs" />
305+
))}
306+
</div>
307+
);
308+
}
309+
// Conditions we can't map to environments fall back to a text label.
290310
return (
291311
<span className="text-xs text-text-dimmed">{conditionLabel(conditionalDeny.conditions)}</span>
292312
);
@@ -298,6 +318,30 @@ function RoleCell({
298318
);
299319
}
300320

321+
const ENV_TYPES = ["DEVELOPMENT", "STAGING", "PREVIEW", "PRODUCTION"] as const;
322+
type EnvType = (typeof ENV_TYPES)[number];
323+
324+
// A conditional `cannot` rule denies the permission where the resource matches
325+
// its condition, so the permission stays allowed everywhere else. Translate the
326+
// envType condition into the set of environments where it's still allowed, or
327+
// null when we can't interpret it (caller falls back to a text label).
328+
function allowedEnvTypesFromDeny(conditions: Record<string, unknown>): EnvType[] | null {
329+
const envType = conditions.envType;
330+
// Equality, e.g. { envType: "PRODUCTION" } → denied in prod, allowed elsewhere.
331+
if (typeof envType === "string") {
332+
return ENV_TYPES.includes(envType as EnvType)
333+
? ENV_TYPES.filter((t) => t !== envType)
334+
: null;
335+
}
336+
// Negation, e.g. { envType: { $ne: "DEVELOPMENT" } } → denied everywhere except
337+
// DEVELOPMENT, so allowed only in DEVELOPMENT.
338+
if (envType && typeof envType === "object" && "$ne" in envType) {
339+
const ne = (envType as { $ne: unknown }).$ne;
340+
return typeof ne === "string" && ENV_TYPES.includes(ne as EnvType) ? [ne as EnvType] : null;
341+
}
342+
return null;
343+
}
344+
301345
// Only `envType` is supported today.
302346
function conditionLabel(conditions: Record<string, unknown>): string {
303347
if (typeof conditions.envType === "string") {

0 commit comments

Comments
 (0)