-
Notifications
You must be signed in to change notification settings - Fork 78
feat(FR-2555): add useCurrentUserProjectRoles hook with myRoles RBAC query #6651
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
graphite-app
merged 1 commit into
main
from
04-13-feat_fr-2209_add_usecurrentuserprojectroles_hook_with_myroles_rbac_query
Apr 14, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
186 changes: 186 additions & 0 deletions
186
react/src/hooks/__tests__/useCurrentUserProjectRoles.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| /** | ||
| @license | ||
| Copyright (c) 2015-2026 Lablup Inc. All rights reserved. | ||
| */ | ||
| import { | ||
| deriveProjectAdminIds, | ||
| MyRolesAssignmentNode, | ||
| } from '../useCurrentUserProjectRoles'; | ||
|
|
||
| /** | ||
| * These tests cover the pure admin-scope derivation logic that powers | ||
| * `useCurrentUserProjectRoles`. The hook itself is a thin Relay + | ||
| * baiClient wrapper — testing it end-to-end requires spinning up the full | ||
| * suspense/Relay environment, while the derivation rules (primary vs | ||
| * fallback, UUID normalization, priority) are where the correctness-sensitive | ||
| * behavior lives. | ||
| * | ||
| * Fixtures correspond to the five cases called out in the acceptance criteria: | ||
| * superadmin-only, domainAdmin-only, projectAdmin-only, mixed (super + project), | ||
| * and none. | ||
| */ | ||
| describe('deriveProjectAdminIds', () => { | ||
| const makePermissionEdge = ( | ||
| scopeType: string, | ||
| entityType: string, | ||
| scopeId: string, | ||
| ) => ({ | ||
| node: { scopeType, entityType, scopeId }, | ||
| }); | ||
|
|
||
| const makeAssignment = ( | ||
| roleName: string | null | undefined, | ||
| permissions: ReturnType<typeof makePermissionEdge>[] = [], | ||
| ): MyRolesAssignmentNode => ({ | ||
| role: { | ||
| name: roleName, | ||
| permissions: { edges: permissions }, | ||
| }, | ||
| }); | ||
|
|
||
| it('returns empty array when there are no assignments (none case)', () => { | ||
| expect(deriveProjectAdminIds([])).toEqual([]); | ||
| }); | ||
|
|
||
| it('returns empty array for superadmin-only assignments with no project-scoped permissions', () => { | ||
| // Superadmin typically has DOMAIN-scoped or unscoped permissions — no | ||
| // PROJECT + PROJECT_ADMIN_PAGE permission should surface. | ||
| const assignments = [ | ||
| makeAssignment('role_superadmin', [ | ||
| makePermissionEdge('DOMAIN', 'DOMAIN_ADMIN_PAGE', 'default'), | ||
| makePermissionEdge('PROJECT', 'SESSION', 'some-project'), | ||
| ]), | ||
| ]; | ||
| expect(deriveProjectAdminIds(assignments)).toEqual([]); | ||
| }); | ||
|
|
||
| it('returns empty array for domainAdmin-only assignments', () => { | ||
| const assignments = [ | ||
| makeAssignment('role_domain_default_admin', [ | ||
| makePermissionEdge('DOMAIN', 'DOMAIN_ADMIN_PAGE', 'default'), | ||
| ]), | ||
| ]; | ||
| expect(deriveProjectAdminIds(assignments)).toEqual([]); | ||
| }); | ||
|
|
||
| it('detects projectAdmin via PROJECT_ADMIN_PAGE permission (primary signal)', () => { | ||
| const assignments = [ | ||
| makeAssignment('role_project_abcd1234_admin', [ | ||
| makePermissionEdge( | ||
| 'PROJECT', | ||
| 'PROJECT_ADMIN_PAGE', | ||
| 'abcd1234-5678-90ab-cdef-000000000000', | ||
| ), | ||
| ]), | ||
| ]; | ||
| expect(deriveProjectAdminIds(assignments)).toEqual(['abcd1234']); | ||
| }); | ||
|
|
||
| it('strips hyphens from UUID scopeId and uses the first 8 hex chars', () => { | ||
| const assignments = [ | ||
| makeAssignment('ignored-name', [ | ||
| makePermissionEdge( | ||
| 'PROJECT', | ||
| 'PROJECT_ADMIN_PAGE', | ||
| '1234abcd-ef01-2345-6789-0abcdef01234', | ||
| ), | ||
| ]), | ||
| ]; | ||
| expect(deriveProjectAdminIds(assignments)).toEqual(['1234abcd']); | ||
| }); | ||
|
|
||
| it('falls back to role-name regex when permission signal is missing', () => { | ||
| const assignments = [makeAssignment('role_project_deadbeef_admin', [])]; | ||
| expect(deriveProjectAdminIds(assignments)).toEqual(['deadbeef']); | ||
| }); | ||
|
|
||
| it('prefers permission signal over role-name regex when both are present', () => { | ||
| // Permission indicates project `aabbccdd`, but role name says `deadbeef`. | ||
| // The primary signal must win. | ||
| const assignments = [ | ||
| makeAssignment('role_project_deadbeef_admin', [ | ||
| makePermissionEdge( | ||
| 'PROJECT', | ||
| 'PROJECT_ADMIN_PAGE', | ||
| 'aabbccdd-0000-0000-0000-000000000000', | ||
| ), | ||
| ]), | ||
| ]; | ||
| expect(deriveProjectAdminIds(assignments)).toEqual(['aabbccdd']); | ||
| }); | ||
|
|
||
| it('deduplicates project IDs across multiple assignments', () => { | ||
| const assignments = [ | ||
| makeAssignment('role_project_aabbccdd_admin', [ | ||
| makePermissionEdge( | ||
| 'PROJECT', | ||
| 'PROJECT_ADMIN_PAGE', | ||
| 'aabbccdd-0000-0000-0000-000000000000', | ||
| ), | ||
| ]), | ||
| makeAssignment('role_project_aabbccdd_admin', [ | ||
| makePermissionEdge( | ||
| 'PROJECT', | ||
| 'PROJECT_ADMIN_PAGE', | ||
| 'aabbccdd-0000-0000-0000-000000000000', | ||
| ), | ||
| ]), | ||
| ]; | ||
| expect(deriveProjectAdminIds(assignments)).toEqual(['aabbccdd']); | ||
| }); | ||
|
|
||
| it('mixed case: reports project-admin IDs even when super/domain admin perms are also present', () => { | ||
| // Mixed super + project admin. The hook returns project IDs; the super-admin | ||
| // signal is sourced separately from baiClient in the real hook. | ||
| const assignments = [ | ||
| makeAssignment('role_superadmin', [ | ||
| makePermissionEdge('DOMAIN', 'DOMAIN_ADMIN_PAGE', 'default'), | ||
| ]), | ||
| makeAssignment('role_project_abcd1234_admin', [ | ||
| makePermissionEdge( | ||
| 'PROJECT', | ||
| 'PROJECT_ADMIN_PAGE', | ||
| 'abcd1234-0000-0000-0000-000000000000', | ||
| ), | ||
| ]), | ||
| ]; | ||
| expect(deriveProjectAdminIds(assignments)).toEqual(['abcd1234']); | ||
| }); | ||
|
|
||
| it('ignores PROJECT scope entries for non-PROJECT_ADMIN_PAGE entity types', () => { | ||
| const assignments = [ | ||
| makeAssignment('role_project_member', [ | ||
| makePermissionEdge( | ||
| 'PROJECT', | ||
| 'SESSION', | ||
| 'abcd1234-0000-0000-0000-000000000000', | ||
| ), | ||
| makePermissionEdge( | ||
| 'PROJECT', | ||
| 'VFOLDER', | ||
| 'abcd1234-0000-0000-0000-000000000000', | ||
| ), | ||
| ]), | ||
| ]; | ||
| expect(deriveProjectAdminIds(assignments)).toEqual([]); | ||
| }); | ||
|
|
||
| it('handles malformed role names without throwing and returns empty', () => { | ||
| const assignments = [ | ||
| makeAssignment('not-a-project-role', []), | ||
| makeAssignment('role_project_XYZ_admin', []), // non-hex chars | ||
| makeAssignment(null, []), | ||
| makeAssignment(undefined, []), | ||
| ]; | ||
| expect(deriveProjectAdminIds(assignments)).toEqual([]); | ||
| }); | ||
|
|
||
| it('tolerates assignments with missing role object (graceful)', () => { | ||
| const assignments: MyRolesAssignmentNode[] = [ | ||
| { role: null }, | ||
| { role: undefined }, | ||
| {} as MyRolesAssignmentNode, | ||
| ]; | ||
| expect(deriveProjectAdminIds(assignments)).toEqual([]); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,211 @@ | ||
| /** | ||
| @license | ||
| Copyright (c) 2015-2026 Lablup Inc. All rights reserved. | ||
| */ | ||
| import { useSuspendedBackendaiClient } from '.'; | ||
| import { useCurrentUserProjectRolesQuery } from '../__generated__/useCurrentUserProjectRolesQuery.graphql'; | ||
| import { graphql, useLazyLoadQuery } from 'react-relay'; | ||
|
|
||
| /** | ||
| * Regex matching the backend's auto-generated project-admin role name. | ||
| * Backend uses short project IDs (UUID with hyphens stripped → 32 hex chars), | ||
| * but only the first 8 hex characters are embedded in the role name. | ||
| * | ||
| * Example: `role_project_abcd1234_admin` | ||
| */ | ||
| const PROJECT_ADMIN_ROLE_NAME_RE = /^role_project_([0-9a-f]{8})_admin$/i; | ||
|
|
||
| /** | ||
| * Minimal shape of a single role assignment node returned by the `myRoles` query. | ||
| * Kept as an interface so tests can construct fixtures without importing Relay | ||
| * generated types. All properties are `readonly` to stay structurally compatible | ||
| * with Relay's generated readonly shapes without requiring a type assertion. | ||
| */ | ||
| export interface MyRolesAssignmentNode { | ||
| readonly role?: { | ||
| readonly name?: string | null; | ||
| readonly permissions?: { | ||
| readonly edges?: ReadonlyArray<{ | ||
| readonly node?: { | ||
| readonly scopeType?: string | null; | ||
| readonly scopeId?: string | null; | ||
| readonly entityType?: string | null; | ||
| } | null; | ||
| } | null> | null; | ||
| } | null; | ||
| } | null; | ||
| } | ||
|
|
||
| export interface CurrentUserProjectRolesResult { | ||
| /** `true` when the authenticated user is a super-admin (derived from baiClient). */ | ||
| isSuperAdmin: boolean; | ||
| /** Domain names the user has domain-admin rights over (derived from baiClient for now). */ | ||
| domainAdminDomains: string[]; | ||
| /** | ||
| * Short project IDs (first 8 hex chars of the UUID, hyphens stripped) the user | ||
| * has project-admin rights over. | ||
| * | ||
| * Primary signal: permissions where `scopeType === 'PROJECT'` and | ||
| * `entityType === 'PROJECT_ADMIN_PAGE'`. | ||
| * Fallback: role name regex `role_project_<8-hex>_admin` (only used when | ||
| * the primary signal returned nothing, for older cores that don't yet grant | ||
| * the `PROJECT_ADMIN_PAGE` permission). | ||
| */ | ||
| projectAdminIds: string[]; | ||
| /** Raw list of role-assignment nodes, exposed for advanced consumers. */ | ||
| rawAssignments: ReadonlyArray<MyRolesAssignmentNode>; | ||
| } | ||
|
|
||
| /** | ||
| * Strip hyphens from a UUID string and return the first 8 hex characters lowercased. | ||
| * Returns `null` if the input is not a non-empty string or doesn't yield 8+ hex chars. | ||
| */ | ||
| const toShortProjectId = (scopeId?: string | null): string | null => { | ||
| if (!scopeId) return null; | ||
| const stripped = scopeId.replace(/-/g, '').toLowerCase(); | ||
| if (stripped.length < 8) return null; | ||
| const short = stripped.slice(0, 8); | ||
| return /^[0-9a-f]{8}$/.test(short) ? short : null; | ||
| }; | ||
|
|
||
| /** | ||
| * Pure, framework-free function that derives the admin-scope data from a list | ||
| * of raw `myRoles` assignment nodes. Exposed for unit testing and for reuse | ||
| * when assignments are obtained from a non-Relay source. | ||
| */ | ||
| export const deriveProjectAdminIds = ( | ||
| assignments: ReadonlyArray<MyRolesAssignmentNode>, | ||
| ): string[] => { | ||
| const fromPermissions = new Set<string>(); | ||
| const fromRoleNames = new Set<string>(); | ||
|
|
||
| for (const assignment of assignments) { | ||
| const role = assignment?.role; | ||
| if (!role) continue; | ||
|
|
||
| // Primary: PROJECT scope + PROJECT_ADMIN_PAGE entity. | ||
| const permissionEdges = role.permissions?.edges ?? []; | ||
| for (const edge of permissionEdges) { | ||
| const node = edge?.node; | ||
| if (!node) continue; | ||
| if ( | ||
| node.scopeType === 'PROJECT' && | ||
| node.entityType === 'PROJECT_ADMIN_PAGE' | ||
| ) { | ||
| const shortId = toShortProjectId(node.scopeId); | ||
| if (shortId) fromPermissions.add(shortId); | ||
| } | ||
| } | ||
|
|
||
| // Fallback: role name regex, collected separately so primary wins when present. | ||
| const match = role.name ? PROJECT_ADMIN_ROLE_NAME_RE.exec(role.name) : null; | ||
| if (match?.[1]) { | ||
| fromRoleNames.add(match[1].toLowerCase()); | ||
| } | ||
| } | ||
|
|
||
| // Sort lexicographically so the returned array is deterministic regardless | ||
| // of GraphQL connection ordering — prevents unnecessary re-renders and | ||
| // flaky reference-equality comparisons across fetches. | ||
| if (fromPermissions.size > 0) { | ||
| return Array.from(fromPermissions).sort(); | ||
| } | ||
| return Array.from(fromRoleNames).sort(); | ||
| }; | ||
|
|
||
| /** | ||
| * Hook that inspects the current user's RBAC role assignments and reports which | ||
| * projects they have project-admin scope over. | ||
| * | ||
| * Uses the `myRoles` query (added in core 26.3.0). On older cores that do not | ||
| * implement it, `@catch(to: RESULT)` makes the field resolve to `{ ok: false }` | ||
| * and the hook returns empty admin arrays instead of throwing — so general | ||
| * pages continue to render. | ||
| * | ||
| * Super-admin / domain-admin detection is sourced from the backendaiclient | ||
| * (the legacy signals the existing codebase already relies on), since those | ||
| * roles are outside the per-project scope this hook is concerned with. | ||
| */ | ||
| export const useCurrentUserProjectRoles = (): CurrentUserProjectRolesResult => { | ||
| const baiClient = useSuspendedBackendaiClient(); | ||
|
|
||
| const data = useLazyLoadQuery<useCurrentUserProjectRolesQuery>( | ||
| graphql` | ||
| query useCurrentUserProjectRolesQuery { | ||
| myRolesResult: myRoles(first: 100) @catch(to: RESULT) { | ||
| edges { | ||
| node { | ||
| id | ||
| role { | ||
| id | ||
| name | ||
| permissions(first: 200) { | ||
| edges { | ||
| node { | ||
| id | ||
| scopeType | ||
| scopeId | ||
| entityType | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| `, | ||
| {}, | ||
| { | ||
| // store-or-network keeps the result cached across pages for the session. | ||
| fetchPolicy: 'store-or-network', | ||
| }, | ||
| ); | ||
|
|
||
| const assignments: ReadonlyArray<MyRolesAssignmentNode> = | ||
| data.myRolesResult?.ok === true | ||
| ? (data.myRolesResult.value?.edges | ||
| ?.map((edge) => edge?.node) | ||
| .filter((node) => Boolean(node)) ?? []) | ||
| : []; | ||
|
|
||
| const projectAdminIds = deriveProjectAdminIds(assignments); | ||
|
|
||
| const isSuperAdmin: boolean = !!baiClient?.is_superadmin; | ||
| // Domain-admin detection via `myRoles` is out of scope for this PR (no stable | ||
| // signal yet agreed with backend). Fall back to the existing baiClient | ||
| // heuristic: non-super admins whose legacy role === 'admin'. | ||
| const isLegacyAdmin: boolean = !!baiClient?.is_admin && !isSuperAdmin; | ||
| const domainName: string | undefined = | ||
| typeof baiClient?.current_domain === 'string' | ||
| ? baiClient.current_domain | ||
| : baiClient?._config?.domainName; | ||
| const domainAdminDomains: string[] = | ||
| isLegacyAdmin && domainName ? [domainName] : []; | ||
|
|
||
| return { | ||
| isSuperAdmin, | ||
| domainAdminDomains, | ||
| projectAdminIds, | ||
| rawAssignments: assignments, | ||
| }; | ||
| }; | ||
|
|
||
| export type EffectiveAdminRole = | ||
| | 'superadmin' | ||
| | 'domainAdmin' | ||
| | 'projectAdmin' | ||
| | 'none'; | ||
|
|
||
| /** | ||
| * Derived hook returning the user's effective admin role with priority: | ||
| * super > domain > project > none. | ||
| */ | ||
| export const useEffectiveAdminRole = (): EffectiveAdminRole => { | ||
| const { isSuperAdmin, domainAdminDomains, projectAdminIds } = | ||
| useCurrentUserProjectRoles(); | ||
| if (isSuperAdmin) return 'superadmin'; | ||
| if (domainAdminDomains.length > 0) return 'domainAdmin'; | ||
| if (projectAdminIds.length > 0) return 'projectAdmin'; | ||
| return 'none'; | ||
| }; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.