Skip to content

Commit ae474ce

Browse files
committed
feat(rbac): add rbac frontend
1 parent 7436e7e commit ae474ce

File tree

14 files changed

+4024
-75
lines changed

14 files changed

+4024
-75
lines changed

frontend/src/app/organization/layout.tsx

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { AuthGuard } from "@/components/auth/auth-guard"
55
import { CenteredSpinner } from "@/components/loading/spinner"
66
import { OrganizationSidebar } from "@/components/sidebar/organization-sidebar"
77
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
8+
import { ScopeProvider } from "@/providers/scopes"
89

910
export default function OrganizationLayout({
1011
children,
@@ -13,18 +14,20 @@ export default function OrganizationLayout({
1314
}) {
1415
return (
1516
<AuthGuard requireAuth requireOrgAdmin>
16-
<SidebarProvider>
17-
<OrganizationSidebar />
18-
<SidebarInset>
19-
<div className="flex h-full flex-1 flex-col">
20-
<div className="flex-1 overflow-auto">
21-
<div className="container py-16">
22-
<Suspense fallback={<CenteredSpinner />}>{children}</Suspense>
17+
<ScopeProvider>
18+
<SidebarProvider>
19+
<OrganizationSidebar />
20+
<SidebarInset>
21+
<div className="flex h-full flex-1 flex-col">
22+
<div className="flex-1 overflow-auto">
23+
<div className="container py-16">
24+
<Suspense fallback={<CenteredSpinner />}>{children}</Suspense>
25+
</div>
2326
</div>
2427
</div>
25-
</div>
26-
</SidebarInset>
27-
</SidebarProvider>
28+
</SidebarInset>
29+
</SidebarProvider>
30+
</ScopeProvider>
2831
</AuthGuard>
2932
)
3033
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { Metadata } from "next"
2+
3+
export const metadata: Metadata = {
4+
title: "Access control | Organization",
5+
}
6+
7+
export default function RbacLayout({
8+
children,
9+
}: {
10+
children: React.ReactNode
11+
}) {
12+
return children
13+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"use client"
2+
3+
import { useState } from "react"
4+
import { OrgRbacAssignments } from "@/components/organization/org-rbac-assignments"
5+
import { OrgRbacGroups } from "@/components/organization/org-rbac-groups"
6+
import { OrgRbacRoles } from "@/components/organization/org-rbac-roles"
7+
import { OrgRbacScopes } from "@/components/organization/org-rbac-scopes"
8+
import { OrgRbacUserAssignments } from "@/components/organization/org-rbac-user-assignments"
9+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
10+
11+
type RbacTab =
12+
| "roles"
13+
| "groups"
14+
| "scopes"
15+
| "assignments"
16+
| "user-assignments"
17+
18+
export default function RbacSettingsPage() {
19+
const [activeTab, setActiveTab] = useState<RbacTab>("roles")
20+
21+
return (
22+
<div className="size-full overflow-auto">
23+
<div className="container flex h-full max-w-[1200px] flex-col space-y-8 py-6">
24+
<div className="flex w-full">
25+
<div className="items-start space-y-3 text-left">
26+
<h2 className="text-2xl font-semibold tracking-tight">
27+
Access control
28+
</h2>
29+
<p className="text-md text-muted-foreground">
30+
Manage roles, groups, and permissions for your organization.
31+
Configure fine-grained access control with scopes and assign
32+
permissions to users and groups.
33+
</p>
34+
</div>
35+
</div>
36+
37+
<Tabs
38+
value={activeTab}
39+
onValueChange={(v) => setActiveTab(v as RbacTab)}
40+
className="w-full"
41+
>
42+
<TabsList className="grid w-full max-w-[650px] grid-cols-5">
43+
<TabsTrigger value="roles">Roles</TabsTrigger>
44+
<TabsTrigger value="groups">Groups</TabsTrigger>
45+
<TabsTrigger value="assignments">Group assignments</TabsTrigger>
46+
<TabsTrigger value="user-assignments">User assignments</TabsTrigger>
47+
<TabsTrigger value="scopes">Scopes</TabsTrigger>
48+
</TabsList>
49+
50+
<TabsContent value="roles" className="mt-6">
51+
<OrgRbacRoles />
52+
</TabsContent>
53+
54+
<TabsContent value="groups" className="mt-6">
55+
<OrgRbacGroups />
56+
</TabsContent>
57+
58+
<TabsContent value="assignments" className="mt-6">
59+
<OrgRbacAssignments />
60+
</TabsContent>
61+
62+
<TabsContent value="user-assignments" className="mt-6">
63+
<OrgRbacUserAssignments />
64+
</TabsContent>
65+
66+
<TabsContent value="scopes" className="mt-6">
67+
<OrgRbacScopes />
68+
</TabsContent>
69+
</Tabs>
70+
</div>
71+
</div>
72+
)
73+
}

frontend/src/app/workspaces/layout.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { useAuth, useAuthActions } from "@/hooks/use-auth"
1919
import { useOrgMembership } from "@/hooks/use-org-membership"
2020
import { useWorkspaceManager } from "@/lib/hooks"
2121
import { WorkflowBuilderProvider } from "@/providers/builder"
22+
import { ScopeProvider } from "@/providers/scopes"
2223
import { WorkflowProvider } from "@/providers/workflow"
2324
import { WorkspaceIdProvider } from "@/providers/workspace-id"
2425

@@ -77,13 +78,18 @@ export default function WorkspaceLayout({
7778

7879
return (
7980
<WorkspaceIdProvider workspaceId={selectedWorkspaceId}>
80-
{workflowId ? (
81-
<WorkflowView workspaceId={selectedWorkspaceId} workflowId={workflowId}>
81+
<ScopeProvider>
82+
{workflowId ? (
83+
<WorkflowView
84+
workspaceId={selectedWorkspaceId}
85+
workflowId={workflowId}
86+
>
87+
<WorkspaceChildren>{children}</WorkspaceChildren>
88+
</WorkflowView>
89+
) : (
8290
<WorkspaceChildren>{children}</WorkspaceChildren>
83-
</WorkflowView>
84-
) : (
85-
<WorkspaceChildren>{children}</WorkspaceChildren>
86-
)}
91+
)}
92+
</ScopeProvider>
8793
</WorkspaceIdProvider>
8894
)
8995
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"use client"
2+
3+
import type { ReactNode } from "react"
4+
import { CenteredSpinner } from "@/components/loading/spinner"
5+
import { useScopes } from "@/providers/scopes"
6+
7+
interface ScopeGuardProps {
8+
/**
9+
* Single scope to check. If both `scope` and `scopes` are provided,
10+
* this is added to the scopes array.
11+
*/
12+
scope?: string
13+
/**
14+
* Array of scopes to check against.
15+
*/
16+
scopes?: string[]
17+
/**
18+
* If true, requires all scopes to be present.
19+
* If false or undefined, requires any one of the scopes.
20+
* @default false
21+
*/
22+
all?: boolean
23+
/**
24+
* Content to render if the user has the required scope(s).
25+
*/
26+
children: ReactNode
27+
/**
28+
* Content to render if the user doesn't have the required scope(s).
29+
* If not provided, nothing is rendered when access is denied.
30+
*/
31+
fallback?: ReactNode
32+
/**
33+
* Content to render while loading scope information.
34+
* Defaults to a centered spinner.
35+
*/
36+
loading?: ReactNode
37+
}
38+
39+
/**
40+
* A component that conditionally renders its children based on user scopes.
41+
*
42+
* @example
43+
* // Single scope check
44+
* <ScopeGuard scope="workflow:create" fallback={<DisabledButton />}>
45+
* <CreateWorkflowButton />
46+
* </ScopeGuard>
47+
*
48+
* @example
49+
* // Multiple scopes, any match
50+
* <ScopeGuard scopes={["workflow:read", "workflow:create"]} fallback={null}>
51+
* <WorkflowSection />
52+
* </ScopeGuard>
53+
*
54+
* @example
55+
* // Multiple scopes, all required
56+
* <ScopeGuard scopes={["workflow:read", "workflow:execute"]} all fallback={<Disabled />}>
57+
* <RunWorkflowButton />
58+
* </ScopeGuard>
59+
*/
60+
export function ScopeGuard({
61+
scope,
62+
scopes: scopesProp,
63+
all = false,
64+
children,
65+
fallback = null,
66+
loading,
67+
}: ScopeGuardProps) {
68+
const { hasScope, hasAnyScope, hasAllScopes, isLoading } = useScopes()
69+
70+
// Combine scope and scopes into a single array
71+
const requiredScopes: string[] = [
72+
...(scope ? [scope] : []),
73+
...(scopesProp ?? []),
74+
]
75+
76+
// If no scopes specified, render children (no restriction)
77+
if (requiredScopes.length === 0) {
78+
return <>{children}</>
79+
}
80+
81+
// Show loading state
82+
if (isLoading) {
83+
return <>{loading ?? <CenteredSpinner />}</>
84+
}
85+
86+
// Check scopes
87+
let hasAccess: boolean
88+
if (requiredScopes.length === 1) {
89+
hasAccess = hasScope(requiredScopes[0])
90+
} else if (all) {
91+
hasAccess = hasAllScopes(requiredScopes)
92+
} else {
93+
hasAccess = hasAnyScope(requiredScopes)
94+
}
95+
96+
if (!hasAccess) {
97+
return <>{fallback}</>
98+
}
99+
100+
return <>{children}</>
101+
}
102+
103+
/**
104+
* A hook-based alternative to ScopeGuard for cases where you need
105+
* programmatic access to the permission check result.
106+
*
107+
* @example
108+
* const canCreate = useScopeCheck("workflow:create")
109+
* if (canCreate) {
110+
* // Show create button
111+
* }
112+
*/
113+
export function useScopeCheck(
114+
scope?: string,
115+
scopes?: string[],
116+
options?: { all?: boolean }
117+
): boolean | undefined {
118+
const { hasScope, hasAnyScope, hasAllScopes, isLoading } = useScopes()
119+
120+
const requiredScopes: string[] = [
121+
...(scope ? [scope] : []),
122+
...(scopes ?? []),
123+
]
124+
125+
if (isLoading) {
126+
return undefined
127+
}
128+
129+
if (requiredScopes.length === 0) {
130+
return true
131+
}
132+
133+
if (requiredScopes.length === 1) {
134+
return hasScope(requiredScopes[0])
135+
}
136+
137+
if (options?.all) {
138+
return hasAllScopes(requiredScopes)
139+
}
140+
141+
return hasAnyScope(requiredScopes)
142+
}

0 commit comments

Comments
 (0)