Fix permissions infinite failed request
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"];
|
||||
|
||||
Reference in New Issue
Block a user