diff --git a/apps/console/src/permissions/Authorized.tsx b/apps/console/src/permissions/Authorized.tsx index 1385d731c..e5e81bfc1 100644 --- a/apps/console/src/permissions/Authorized.tsx +++ b/apps/console/src/permissions/Authorized.tsx @@ -1,6 +1,6 @@ -import { type ReactNode, useState, useEffect } from "react"; +import { type ReactNode } from "react"; import { useParams } from "react-router"; -import { isAuthorized } from "./permissions"; +import { usePermissions } from "./permissions"; type Props = { entity: string; @@ -38,41 +38,9 @@ export function Authorized({ fallback = null, }: Props) { const { organizationId } = useParams(); - const [hasAccess, setHasAccess] = useState(null); + const { loading, error, isAuthorized } = usePermissions(organizationId || ""); - useEffect(() => { - if (!organizationId) { - setHasAccess(false); - return; - } - - // Try to check authorization, catching promise throws - try { - const authorized = isAuthorized(organizationId, entity, action); - setHasAccess(authorized); - } catch (promise) { - // If a promise is thrown (Suspense pattern), wait for it - if (promise instanceof Promise) { - promise - .then(() => { - // Permissions loaded, try again - try { - const authorized = isAuthorized(organizationId, entity, action); - setHasAccess(authorized); - } catch { - setHasAccess(false); - } - }) - .catch(() => { - setHasAccess(false); - }); - } else { - setHasAccess(false); - } - } - }, [organizationId, entity, action]); - - if (!organizationId || hasAccess === null || hasAccess === false) { + if (!organizationId || loading || error || !isAuthorized(entity, action)) { return fallback; } diff --git a/apps/console/src/permissions/permissions.ts b/apps/console/src/permissions/permissions.ts index 24dbde9cd..4b78b273a 100644 --- a/apps/console/src/permissions/permissions.ts +++ b/apps/console/src/permissions/permissions.ts @@ -1,3 +1,5 @@ +import { useEffect, useState } from 'react'; + // Authorization system that checks permissions from the backend // Permissions are fetched per organization from /authz/:organizationId/permissions // Format: { permissions: { "Document": { "node": true, "updateDocument": true }, "Organization": { "createDocument": true } }, role: "ADMIN" } @@ -9,33 +11,30 @@ type PermissionsResponse = { role: string; }; -let cachedPermissions: EntityPermissions | null = null; -let cachedRole: string | null = null; -let cachePromise: Promise | null = null; -let currentOrganizationId: string | null = null; +type PermissionsCache = { + [organizationId: string]: { + permissions: EntityPermissions; + role: string; + }; +}; + +const cache: PermissionsCache = {}; +const pendingRequests: Map> = new Map(); /** * Fetch permissions for the current user's role in the organization */ function fetchPermissions(organizationId: string): Promise { - if (cachedPermissions && cachedRole && currentOrganizationId === organizationId) { - return Promise.resolve({ permissions: cachedPermissions, role: cachedRole }); + if (cache[organizationId]) { + return Promise.resolve(cache[organizationId]); } - if (cachePromise && currentOrganizationId === organizationId) { - return cachePromise; + const pending = pendingRequests.get(organizationId); + if (pending) { + return pending; } - if (currentOrganizationId !== organizationId) { - cachedPermissions = null; - cachedRole = null; - cachePromise = null; - currentOrganizationId = organizationId; - } - - const requestedOrgId = organizationId; - - cachePromise = fetch(`/authz/${encodeURIComponent(organizationId)}/permissions`, { + const promise = fetch(`/authz/${encodeURIComponent(organizationId)}/permissions`, { credentials: 'include', }) .then((response) => { @@ -45,55 +44,138 @@ function fetchPermissions(organizationId: string): Promise return response.json(); }) .then((data: PermissionsResponse) => { - if (currentOrganizationId === requestedOrgId) { - cachedPermissions = data.permissions; - cachedRole = data.role; + if (!data || typeof data.permissions !== 'object' || !data.role) { + throw new Error('Invalid permissions response structure'); } - cachePromise = null; + + cache[organizationId] = { + permissions: data.permissions, + role: data.role, + }; + pendingRequests.delete(organizationId); return data; }) .catch((error) => { - cachePromise = null; - cachedPermissions = null; - cachedRole = null; + pendingRequests.delete(organizationId); throw error; }); - return cachePromise; + pendingRequests.set(organizationId, promise); + return promise; } /** - * Check if the user has permission for an entity and action + * React hook to fetch and manage permissions for an organization + * + * @param organizationId - The organization ID + * @returns Object with loading, error, permissions, role, and helper functions + * + * @example + * const { loading, error, permissions, role, isAuthorized, getAssignableRoles } = usePermissions(orgId); + * + * if (loading) return ; + * if (error) return ; + * if (isAuthorized("Document", "updateDocument")) { ... } + */ +export function usePermissions(organizationId: string) { + const [state, setState] = useState<{ + loading: boolean; + error: Error | null; + permissions: EntityPermissions | null; + role: string | null; + }>({ + loading: true, + error: null, + permissions: null, + role: null, + }); + + useEffect(() => { + setState({ loading: true, error: null, permissions: null, role: null }); + + fetchPermissions(organizationId) + .then((data) => { + setState({ + loading: false, + error: null, + permissions: data.permissions, + role: data.role, + }); + }) + .catch((error) => { + setState({ + loading: false, + error, + permissions: null, + role: null, + }); + }); + }, [organizationId]); + + const checkAuthorized = (entity: string, action: string): boolean => { + if (!state.permissions) return false; + + const entityPermissions = state.permissions[entity]; + if (!entityPermissions) return false; + + return entityPermissions[action] === true; + }; + + const getAssignableRolesList = (): string[] => { + if (!state.role) return []; + + if (state.role === "OWNER" || state.role === "FULL") { + return ["OWNER", "ADMIN", "VIEWER"]; + } + + if (state.role === "ADMIN") { + return ["ADMIN", "VIEWER"]; + } + + return []; + }; + + return { + loading: state.loading, + error: state.error, + permissions: state.permissions, + role: state.role, + isAuthorized: checkAuthorized, + getAssignableRoles: getAssignableRolesList, + }; +} + +/** + * Check if the user has permission for an entity and action (synchronous) + * Returns false if permissions are not loaded yet * * @param organizationId - The organization ID * @param entity - The entity name (e.g., "Document", "Organization", "Vendor") * @param action - The action/field name (e.g., "node", "updateDocument", "createDocument") - * @returns true if the user has permission + * @returns true if the user has permission, false otherwise * * @example - * isAuthorized(orgId, "Document", "get") // Check if user can query Document nodes - * isAuthorized(orgId, "Document", "updateDocument") // Check if user can update documents - * isAuthorized(orgId, "Organization", "createDocument") // Check if user can create documents + * isAuthorized(orgId, "Document", "get") + * isAuthorized(orgId, "Document", "updateDocument") + * isAuthorized(orgId, "Organization", "createDocument") */ export function isAuthorized( organizationId: string, entity: string, action: string ): boolean { - if (!cachedPermissions || currentOrganizationId !== organizationId) { - throw fetchPermissions(organizationId); - } + const cached = cache[organizationId]; + if (!cached) return false; - const entityPermissions = cachedPermissions[entity]; - if (!entityPermissions) { - return false; - } + const entityPermissions = cached.permissions[entity]; + if (!entityPermissions) return false; return entityPermissions[action] === true; } /** * Get the current user's role in the organization + * Returns empty string if not loaded yet * * @param organizationId - The organization ID * @returns The user's role (e.g., "OWNER", "ADMIN", "VIEWER", "FULL") @@ -102,11 +184,8 @@ export function isAuthorized( * getUserRole(orgId) // Returns "ADMIN" */ export function getUserRole(organizationId: string): string { - if (!cachedRole || currentOrganizationId !== organizationId) { - throw fetchPermissions(organizationId); - } - - return cachedRole; + const cached = cache[organizationId]; + return cached?.role || ""; } /** @@ -118,6 +197,7 @@ export function getUserRole(organizationId: string): string { */ export function getAssignableRoles(organizationId: string): string[] { const currentRole = getUserRole(organizationId); + if (!currentRole) return []; if (currentRole === "OWNER" || currentRole === "FULL") { return ["OWNER", "ADMIN", "VIEWER"];