@@ -3,6 +3,7 @@ import { type MetaFunction } from "@remix-run/react";
33import { useState } from "react" ;
44import { type UseDataFunctionReturn , typedjson , useTypedLoaderData } from "remix-typedjson" ;
55import { z } from "zod" ;
6+ import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel" ;
67import { Feedback } from "~/components/Feedback" ;
78import { PageBody , PageContainer } from "~/components/layout/AppLayout" ;
89import { 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];
9295const FALLBACK_GROUP = "Other" ;
9396
9497export 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.
302346function conditionLabel ( conditions : Record < string , unknown > ) : string {
303347 if ( typeof conditions . envType === "string" ) {
0 commit comments