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 { 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<boolean | null>(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;
}

View File

@@ -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<PermissionsResponse> | null = null;
let currentOrganizationId: string | null = null;
type PermissionsCache = {
[organizationId: string]: {
permissions: EntityPermissions;
role: string;
};
};
const cache: PermissionsCache = {};
const pendingRequests: Map<string, Promise<PermissionsResponse>> = new Map();
/**
* Fetch permissions for the current user's role in the organization
*/
function fetchPermissions(organizationId: string): Promise<PermissionsResponse> {
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<PermissionsResponse>
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 <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 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"];