From 9c2a1d27b78acfa7b35e58c1220499a7a8284901 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:04:06 +0200 Subject: [PATCH] feat: replace hardcoded permission map with direct OAuth2 scope checks Remove role-to-permission mapping (usePermissions, RequirePermission) and replace with direct scope reads from the Logto access token JWT. OrgResolver decodes the scope claim after /api/me resolves and stores scopes in Zustand. RequireScope and useScopes replace the old hooks/components across all pages. Co-Authored-By: Claude Sonnet 4.6 --- ui/src/auth/OrgResolver.tsx | 27 +++++++++++++++++---- ui/src/auth/useAuth.ts | 3 +-- ui/src/auth/useOrganization.ts | 16 +++++-------- ui/src/auth/useScopes.ts | 5 ++++ ui/src/components/Layout.tsx | 6 +++-- ui/src/components/RequirePermission.tsx | 13 ---------- ui/src/components/RequireScope.tsx | 13 ++++++++++ ui/src/hooks/usePermissions.ts | 30 ----------------------- ui/src/pages/AppDetailPage.tsx | 32 +++++++++++++------------ ui/src/pages/DashboardPage.tsx | 10 ++++---- ui/src/pages/EnvironmentDetailPage.tsx | 18 +++++++------- ui/src/pages/EnvironmentsPage.tsx | 10 ++++---- ui/src/types/api.ts | 1 - 13 files changed, 87 insertions(+), 97 deletions(-) create mode 100644 ui/src/auth/useScopes.ts delete mode 100644 ui/src/components/RequirePermission.tsx create mode 100644 ui/src/components/RequireScope.tsx delete mode 100644 ui/src/hooks/usePermissions.ts diff --git a/ui/src/auth/OrgResolver.tsx b/ui/src/auth/OrgResolver.tsx index 5a4d309..84624f4 100644 --- a/ui/src/auth/OrgResolver.tsx +++ b/ui/src/auth/OrgResolver.tsx @@ -1,21 +1,23 @@ import { useEffect } from 'react'; +import { useLogto } from '@logto/react'; import { Spinner } from '@cameleer/design-system'; import { useMe } from '../api/hooks'; import { useOrgStore } from './useOrganization'; +import { fetchConfig } from '../config'; /** - * Fetches /api/me and populates the org store with platform admin status - * and tenant-to-org mapping. Renders children once resolved. + * Fetches /api/me and populates the org store with tenant-to-org mapping. + * Also reads OAuth2 scopes from the access token and stores them. + * Renders children once resolved. */ export function OrgResolver({ children }: { children: React.ReactNode }) { const { data: me, isLoading, isError } = useMe(); - const { setIsPlatformAdmin, setOrganizations, setCurrentOrg, currentOrgId } = useOrgStore(); + const { getAccessToken } = useLogto(); + const { setOrganizations, setCurrentOrg, setScopes, currentOrgId } = useOrgStore(); useEffect(() => { if (!me) return; - setIsPlatformAdmin(me.isPlatformAdmin); - // Map tenants: logtoOrgId is the org ID for token scoping, id is the DB UUID const orgEntries = me.tenants.map((t) => ({ id: t.logtoOrgId, @@ -29,6 +31,21 @@ export function OrgResolver({ children }: { children: React.ReactNode }) { if (orgEntries.length === 1 && !currentOrgId) { setCurrentOrg(orgEntries[0].id); } + + // Read scopes from the access token JWT payload + fetchConfig().then((config) => { + if (!config.logtoResource) return; + getAccessToken(config.logtoResource).then((token) => { + if (!token) return; + try { + const payload = JSON.parse(atob(token.split('.')[1])); + const scopeStr = (payload.scope as string) ?? ''; + setScopes(new Set(scopeStr.split(' ').filter(Boolean))); + } catch { + setScopes(new Set()); + } + }).catch(() => setScopes(new Set())); + }); }, [me]); if (isLoading) { diff --git a/ui/src/auth/useAuth.ts b/ui/src/auth/useAuth.ts index 5ed9c7f..a7004bd 100644 --- a/ui/src/auth/useAuth.ts +++ b/ui/src/auth/useAuth.ts @@ -4,7 +4,7 @@ import { useOrgStore } from './useOrganization'; export function useAuth() { const { isAuthenticated, isLoading, signOut, signIn } = useLogto(); - const { currentTenantId, isPlatformAdmin } = useOrgStore(); + const { currentTenantId } = useOrgStore(); const logout = useCallback(() => { signOut(window.location.origin + '/login'); @@ -14,7 +14,6 @@ export function useAuth() { isAuthenticated, isLoading, tenantId: currentTenantId, - isPlatformAdmin, logout, signIn, }; diff --git a/ui/src/auth/useOrganization.ts b/ui/src/auth/useOrganization.ts index 8d12721..303159a 100644 --- a/ui/src/auth/useOrganization.ts +++ b/ui/src/auth/useOrganization.ts @@ -8,23 +8,20 @@ export interface OrgInfo { } interface OrgState { - currentOrgId: string | null; // Logto org ID — used for getAccessToken(resource, orgId) - currentTenantId: string | null; // DB UUID — used for API calls like /api/tenants/{id} - currentOrgRoles: string[] | null; // Logto org roles for the current org (e.g. 'admin', 'member') + currentOrgId: string | null; // Logto org ID — used for getAccessToken(resource, orgId) + currentTenantId: string | null; // DB UUID — used for API calls like /api/tenants/{id} organizations: OrgInfo[]; - isPlatformAdmin: boolean; + scopes: Set; setCurrentOrg: (orgId: string | null) => void; setOrganizations: (orgs: OrgInfo[]) => void; - setIsPlatformAdmin: (value: boolean) => void; - setCurrentOrgRoles: (roles: string[] | null) => void; + setScopes: (scopes: Set) => void; } export const useOrgStore = create((set, get) => ({ currentOrgId: null, currentTenantId: null, - currentOrgRoles: null, organizations: [], - isPlatformAdmin: false, + scopes: new Set(), setCurrentOrg: (orgId) => { const org = get().organizations.find((o) => o.id === orgId); set({ currentOrgId: orgId, currentTenantId: org?.tenantId ?? null }); @@ -38,6 +35,5 @@ export const useOrgStore = create((set, get) => ({ currentTenantId: match?.tenantId ?? get().currentTenantId, }); }, - setIsPlatformAdmin: (value) => set({ isPlatformAdmin: value }), - setCurrentOrgRoles: (roles) => set({ currentOrgRoles: roles }), + setScopes: (scopes) => set({ scopes }), })); diff --git a/ui/src/auth/useScopes.ts b/ui/src/auth/useScopes.ts new file mode 100644 index 0000000..39a407b --- /dev/null +++ b/ui/src/auth/useScopes.ts @@ -0,0 +1,5 @@ +import { useOrgStore } from './useOrganization'; + +export function useScopes() { + return useOrgStore((state) => state.scopes); +} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index e81eda6..1cec15f 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -6,6 +6,7 @@ import { TopBar, } from '@cameleer/design-system'; import { useAuth } from '../auth/useAuth'; +import { useScopes } from '../auth/useScopes'; import { EnvironmentTree } from './EnvironmentTree'; // Simple SVG logo mark for the sidebar header @@ -100,7 +101,8 @@ function PlatformIcon() { export function Layout() { const navigate = useNavigate(); - const { logout, isPlatformAdmin } = useAuth(); + const { logout } = useAuth(); + const scopes = useScopes(); const [envSectionOpen, setEnvSectionOpen] = useState(true); const [collapsed, setCollapsed] = useState(false); @@ -144,7 +146,7 @@ export function Layout() { {/* Platform Admin section */} - {isPlatformAdmin && ( + {scopes.has('platform:admin') && ( } label="Platform" diff --git a/ui/src/components/RequirePermission.tsx b/ui/src/components/RequirePermission.tsx deleted file mode 100644 index f870bf6..0000000 --- a/ui/src/components/RequirePermission.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { usePermissions } from '../hooks/usePermissions'; - -interface Props { - permission: string; - children: React.ReactNode; - fallback?: React.ReactNode; -} - -export function RequirePermission({ permission, children, fallback }: Props) { - const { has } = usePermissions(); - if (!has(permission)) return fallback ? <>{fallback} : null; - return <>{children}; -} diff --git a/ui/src/components/RequireScope.tsx b/ui/src/components/RequireScope.tsx new file mode 100644 index 0000000..6e87ea5 --- /dev/null +++ b/ui/src/components/RequireScope.tsx @@ -0,0 +1,13 @@ +import { useScopes } from '../auth/useScopes'; + +interface Props { + scope: string; + children: React.ReactNode; + fallback?: React.ReactNode; +} + +export function RequireScope({ scope, children, fallback }: Props) { + const scopes = useScopes(); + if (!scopes.has(scope)) return fallback ? <>{fallback} : null; + return <>{children}; +} diff --git a/ui/src/hooks/usePermissions.ts b/ui/src/hooks/usePermissions.ts deleted file mode 100644 index 3859511..0000000 --- a/ui/src/hooks/usePermissions.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useOrgStore } from '../auth/useOrganization'; - -const ROLE_PERMISSIONS: Record = { - 'admin': [ - 'tenant:manage', 'billing:manage', 'team:manage', 'apps:manage', - 'apps:deploy', 'secrets:manage', 'observe:read', 'observe:debug', - 'settings:manage', - ], - 'member': ['apps:deploy', 'observe:read', 'observe:debug'], -}; - -export function usePermissions() { - const { currentOrgRoles } = useOrgStore(); - const roles = currentOrgRoles ?? []; - - const permissions = new Set(); - for (const role of roles) { - const perms = ROLE_PERMISSIONS[role]; - if (perms) perms.forEach((p) => permissions.add(p)); - } - - return { - has: (permission: string) => permissions.has(permission), - canManageApps: permissions.has('apps:manage'), - canDeploy: permissions.has('apps:deploy'), - canManageTenant: permissions.has('tenant:manage'), - canViewObservability: permissions.has('observe:read'), - roles, - }; -} diff --git a/ui/src/pages/AppDetailPage.tsx b/ui/src/pages/AppDetailPage.tsx index 29da674..2e7b382 100644 --- a/ui/src/pages/AppDetailPage.tsx +++ b/ui/src/pages/AppDetailPage.tsx @@ -32,9 +32,9 @@ import { useLogs, useCreateApp, } from '../api/hooks'; -import { RequirePermission } from '../components/RequirePermission'; +import { RequireScope } from '../components/RequireScope'; import { DeploymentStatusBadge } from '../components/DeploymentStatusBadge'; -import { usePermissions } from '../hooks/usePermissions'; +import { useScopes } from '../auth/useScopes'; import type { DeploymentResponse } from '../types/api'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -119,7 +119,9 @@ export function AppDetailPage() { const navigate = useNavigate(); const { toast } = useToast(); const { tenantId } = useAuth(); - const { canManageApps, canDeploy } = usePermissions(); + const scopes = useScopes(); + const canManageApps = scopes.has('apps:manage'); + const canDeploy = scopes.has('apps:deploy'); // Active tab const [activeTab, setActiveTab] = useState('overview'); @@ -399,7 +401,7 @@ export function AppDetailPage() { {/* Action bar */}
- + - + - + - + - + - + - + - + - + - +
@@ -552,11 +554,11 @@ export function AppDetailPage() { )} - + - + diff --git a/ui/src/pages/DashboardPage.tsx b/ui/src/pages/DashboardPage.tsx index 6cb4c1e..85382e9 100644 --- a/ui/src/pages/DashboardPage.tsx +++ b/ui/src/pages/DashboardPage.tsx @@ -10,7 +10,7 @@ import { } from '@cameleer/design-system'; import { useAuth } from '../auth/useAuth'; import { useTenant, useEnvironments, useApps } from '../api/hooks'; -import { RequirePermission } from '../components/RequirePermission'; +import { RequireScope } from '../components/RequireScope'; import type { EnvironmentResponse, AppResponse } from '../types/api'; // Helper: fetches apps for one environment and reports data upward via effect @@ -126,7 +126,7 @@ export function DashboardPage() { )}
- + - + - + } /> )} diff --git a/ui/src/pages/EnvironmentDetailPage.tsx b/ui/src/pages/EnvironmentDetailPage.tsx index 0724656..32ad884 100644 --- a/ui/src/pages/EnvironmentDetailPage.tsx +++ b/ui/src/pages/EnvironmentDetailPage.tsx @@ -23,7 +23,7 @@ import { useApps, useCreateApp, } from '../api/hooks'; -import { RequirePermission } from '../components/RequirePermission'; +import { RequireScope } from '../components/RequireScope'; import { DeploymentStatusBadge } from '../components/DeploymentStatusBadge'; import type { AppResponse } from '../types/api'; @@ -188,7 +188,7 @@ export function EnvironmentDetailPage() { {/* Header */}
- @@ -201,7 +201,7 @@ export function EnvironmentDetailPage() { onSave={handleRename} placeholder="Environment name" /> - +
- + - - + + - +
@@ -234,11 +234,11 @@ export function EnvironmentDetailPage() { title="No apps yet" description="Deploy your first Camel application to this environment." action={ - + - + } /> ) : ( diff --git a/ui/src/pages/EnvironmentsPage.tsx b/ui/src/pages/EnvironmentsPage.tsx index e53ec8b..31678e8 100644 --- a/ui/src/pages/EnvironmentsPage.tsx +++ b/ui/src/pages/EnvironmentsPage.tsx @@ -15,7 +15,7 @@ import { import type { Column } from '@cameleer/design-system'; import { useAuth } from '../auth/useAuth'; import { useEnvironments, useCreateEnvironment } from '../api/hooks'; -import { RequirePermission } from '../components/RequirePermission'; +import { RequireScope } from '../components/RequireScope'; import type { EnvironmentResponse } from '../types/api'; interface TableRow { @@ -120,11 +120,11 @@ export function EnvironmentsPage() { {/* Page header */}

Environments

- + - +
{/* Table / empty state */} @@ -133,11 +133,11 @@ export function EnvironmentsPage() { title="No environments yet" description="Create your first environment to start deploying Camel applications." action={ - + - + } /> ) : ( diff --git a/ui/src/types/api.ts b/ui/src/types/api.ts index fe17a92..1e3ff9b 100644 --- a/ui/src/types/api.ts +++ b/ui/src/types/api.ts @@ -86,7 +86,6 @@ export interface LogEntry { export interface MeResponse { userId: string; - isPlatformAdmin: boolean; tenants: Array<{ id: string; name: string;