Fix permissions infinite failed request

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-11-14 21:03:28 +01:00
parent f8b33fa451
commit 3fcabc6a62
2 changed files with 127 additions and 79 deletions

View File

@@ -1,6 +1,6 @@
import { type ReactNode, useState, useEffect } from "react"; import { type ReactNode } from "react";
import { useParams } from "react-router"; import { useParams } from "react-router";
import { isAuthorized } from "./permissions"; import { usePermissions } from "./permissions";
type Props = { type Props = {
entity: string; entity: string;
@@ -38,41 +38,9 @@ export function Authorized({
fallback = null, fallback = null,
}: Props) { }: Props) {
const { organizationId } = useParams(); const { organizationId } = useParams();
const [hasAccess, setHasAccess] = useState<boolean | null>(null); const { loading, error, isAuthorized } = usePermissions(organizationId || "");
useEffect(() => { if (!organizationId || loading || error || !isAuthorized(entity, action)) {
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) {
return fallback; return fallback;
} }

View File

@@ -1,3 +1,5 @@
import { useEffect, useState } from 'react';
// Authorization system that checks permissions from the backend // Authorization system that checks permissions from the backend
// Permissions are fetched per organization from /authz/:organizationId/permissions // Permissions are fetched per organization from /authz/:organizationId/permissions
// Format: { permissions: { "Document": { "node": true, "updateDocument": true }, "Organization": { "createDocument": true } }, role: "ADMIN" } // Format: { permissions: { "Document": { "node": true, "updateDocument": true }, "Organization": { "createDocument": true } }, role: "ADMIN" }
@@ -9,33 +11,30 @@ type PermissionsResponse = {
role: string; role: string;
}; };
let cachedPermissions: EntityPermissions | null = null; type PermissionsCache = {
let cachedRole: string | null = null; [organizationId: string]: {
let cachePromise: Promise<PermissionsResponse> | null = null; permissions: EntityPermissions;
let currentOrganizationId: string | null = null; role: string;
};
};
const cache: PermissionsCache = {};
const pendingRequests: Map<string, Promise<PermissionsResponse>> = new Map();
/** /**
* Fetch permissions for the current user's role in the organization * Fetch permissions for the current user's role in the organization
*/ */
function fetchPermissions(organizationId: string): Promise<PermissionsResponse> { function fetchPermissions(organizationId: string): Promise<PermissionsResponse> {
if (cachedPermissions && cachedRole && currentOrganizationId === organizationId) { if (cache[organizationId]) {
return Promise.resolve({ permissions: cachedPermissions, role: cachedRole }); return Promise.resolve(cache[organizationId]);
} }
if (cachePromise && currentOrganizationId === organizationId) { const pending = pendingRequests.get(organizationId);
return cachePromise; if (pending) {
return pending;
} }
if (currentOrganizationId !== organizationId) { const promise = fetch(`/authz/${encodeURIComponent(organizationId)}/permissions`, {
cachedPermissions = null;
cachedRole = null;
cachePromise = null;
currentOrganizationId = organizationId;
}
const requestedOrgId = organizationId;
cachePromise = fetch(`/authz/${encodeURIComponent(organizationId)}/permissions`, {
credentials: 'include', credentials: 'include',
}) })
.then((response) => { .then((response) => {
@@ -45,55 +44,138 @@ function fetchPermissions(organizationId: string): Promise<PermissionsResponse>
return response.json(); return response.json();
}) })
.then((data: PermissionsResponse) => { .then((data: PermissionsResponse) => {
if (currentOrganizationId === requestedOrgId) { if (!data || typeof data.permissions !== 'object' || !data.role) {
cachedPermissions = data.permissions; throw new Error('Invalid permissions response structure');
cachedRole = data.role;
} }
cachePromise = null;
cache[organizationId] = {
permissions: data.permissions,
role: data.role,
};
pendingRequests.delete(organizationId);
return data; return data;
}) })
.catch((error) => { .catch((error) => {
cachePromise = null; pendingRequests.delete(organizationId);
cachedPermissions = null;
cachedRole = null;
throw error; 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 <Spinner />;
* if (error) return <ErrorMessage />;
* 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 organizationId - The organization ID
* @param entity - The entity name (e.g., "Document", "Organization", "Vendor") * @param entity - The entity name (e.g., "Document", "Organization", "Vendor")
* @param action - The action/field name (e.g., "node", "updateDocument", "createDocument") * @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 * @example
* isAuthorized(orgId, "Document", "get") // Check if user can query Document nodes * isAuthorized(orgId, "Document", "get")
* isAuthorized(orgId, "Document", "updateDocument") // Check if user can update documents * isAuthorized(orgId, "Document", "updateDocument")
* isAuthorized(orgId, "Organization", "createDocument") // Check if user can create documents * isAuthorized(orgId, "Organization", "createDocument")
*/ */
export function isAuthorized( export function isAuthorized(
organizationId: string, organizationId: string,
entity: string, entity: string,
action: string action: string
): boolean { ): boolean {
if (!cachedPermissions || currentOrganizationId !== organizationId) { const cached = cache[organizationId];
throw fetchPermissions(organizationId); if (!cached) return false;
}
const entityPermissions = cachedPermissions[entity]; const entityPermissions = cached.permissions[entity];
if (!entityPermissions) { if (!entityPermissions) return false;
return false;
}
return entityPermissions[action] === true; return entityPermissions[action] === true;
} }
/** /**
* Get the current user's role in the organization * Get the current user's role in the organization
* Returns empty string if not loaded yet
* *
* @param organizationId - The organization ID * @param organizationId - The organization ID
* @returns The user's role (e.g., "OWNER", "ADMIN", "VIEWER", "FULL") * @returns The user's role (e.g., "OWNER", "ADMIN", "VIEWER", "FULL")
@@ -102,11 +184,8 @@ export function isAuthorized(
* getUserRole(orgId) // Returns "ADMIN" * getUserRole(orgId) // Returns "ADMIN"
*/ */
export function getUserRole(organizationId: string): string { export function getUserRole(organizationId: string): string {
if (!cachedRole || currentOrganizationId !== organizationId) { const cached = cache[organizationId];
throw fetchPermissions(organizationId); return cached?.role || "";
}
return cachedRole;
} }
/** /**
@@ -118,6 +197,7 @@ export function getUserRole(organizationId: string): string {
*/ */
export function getAssignableRoles(organizationId: string): string[] { export function getAssignableRoles(organizationId: string): string[] {
const currentRole = getUserRole(organizationId); const currentRole = getUserRole(organizationId);
if (!currentRole) return [];
if (currentRole === "OWNER" || currentRole === "FULL") { if (currentRole === "OWNER" || currentRole === "FULL") {
return ["OWNER", "ADMIN", "VIEWER"]; return ["OWNER", "ADMIN", "VIEWER"];