UNPKG

preact-missing-hooks

Version:

A lightweight, extendable collection of missing React-like hooks for Preact — plus fresh, powerful new ones designed specifically for modern Preact apps.

381 lines (349 loc) 12 kB
import { useCallback, useEffect, useRef, useState } from "preact/hooks"; /** Flexible user object; your app can use any shape. */ export type RBACUser = Record<string, unknown>; /** Get auth state (user + optional roles/capabilities). Used by custom source. */ export interface RBACAuthState { user?: RBACUser | null; roles?: string[]; capabilities?: string[]; } /** Pluggable source for current user (and optionally roles/capabilities). */ export type RBACUserSource = | { type: "localStorage"; key: string } | { type: "sessionStorage"; key: string } | { type: "api"; fetch: () => Promise<RBACUser> } | { type: "memory"; getUser: () => RBACUser | null } | { type: "custom"; getAuth: () => RBACAuthState | Promise<RBACAuthState> }; /** Role definition: name + condition to grant this role based on user. */ export interface RBACRoleDefinition { role: string; condition: (user: RBACUser | null) => boolean; } /** Role name -> list of capability strings. Use '*' for full access. */ export type RBACRoleCapabilities = Record<string, string[]>; /** Optional override: get capabilities directly (e.g. from API) instead of deriving from roles. */ export type RBACCapabilitiesOverride = | { type: "localStorage"; key: string } | { type: "sessionStorage"; key: string } | { type: "api"; fetch: () => Promise<string[]> }; export interface UseRBACOptions { /** Where to get the current user (and optionally roles/capabilities if type is 'custom'). */ userSource: RBACUserSource; /** Role definitions: each role has a condition(user) to determine if the user has that role. */ roleDefinitions: RBACRoleDefinition[]; /** Capabilities per role. User gets union of capabilities for all their roles. Use '*' for admin. */ roleCapabilities: RBACRoleCapabilities; /** Optional: fetch capabilities directly (overrides role-derived capabilities when provided). */ capabilitiesOverride?: RBACCapabilitiesOverride; } export interface UseRBACReturn { /** Current user from source, or null. */ user: RBACUser | null; /** Resolved roles for the current user. */ roles: string[]; /** Resolved capabilities (union of role capabilities, or from override). */ capabilities: string[]; /** True when user/roles/capabilities have been resolved (or failed). */ isReady: boolean; /** Error from source (e.g. API or parse). */ error: Error | null; /** Check if the user has the given role. */ hasRole: (role: string) => boolean; /** Check if the user has the given capability (or '*' ). */ hasCapability: (capability: string) => boolean; /** Alias for hasCapability. */ can: (capability: string) => boolean; /** Re-fetch user/roles/capabilities from source. */ refetch: () => Promise<void>; /** Helpers to persist auth to storage (for frontend-only flows). */ setUserInStorage: ( user: RBACUser | null, storage: "localStorage" | "sessionStorage", key: string ) => void; setRolesInStorage: ( roles: string[], storage: "localStorage" | "sessionStorage", key: string ) => void; setCapabilitiesInStorage: ( capabilities: string[], storage: "localStorage" | "sessionStorage", key: string ) => void; } const WILDCARD = "*"; function parseUserFromStorage( type: "localStorage" | "sessionStorage", key: string ): RBACUser | null { if (typeof window === "undefined") return null; const storage = type === "localStorage" ? window.localStorage : window.sessionStorage; try { const raw = storage.getItem(key); if (raw == null) return null; const data = JSON.parse(raw) as unknown; return data && typeof data === "object" ? (data as RBACUser) : null; } catch { return null; } } function parseCapabilitiesFromStorage( type: "localStorage" | "sessionStorage", key: string ): string[] { if (typeof window === "undefined") return []; const storage = type === "localStorage" ? window.localStorage : window.sessionStorage; try { const raw = storage.getItem(key); if (raw == null) return []; const data = JSON.parse(raw) as unknown; return Array.isArray(data) ? data.filter((x): x is string => typeof x === "string") : []; } catch { return []; } } function computeRoles( user: RBACUser | null, roleDefinitions: RBACRoleDefinition[] ): string[] { return roleDefinitions .filter((def) => def.condition(user)) .map((def) => def.role); } function computeCapabilitiesFromRoles( roles: string[], roleCapabilities: RBACRoleCapabilities ): string[] { const set = new Set<string>(); for (const role of roles) { const caps = roleCapabilities[role]; if (Array.isArray(caps)) { for (const c of caps) set.add(c); } } return Array.from(set); } function hasCapabilityImpl( capabilities: string[], capability: string ): boolean { if (capabilities.includes(WILDCARD)) return true; return capabilities.includes(capability); } /** * Frontend-only role-based access control hook. Define roles with conditions, * assign capabilities per role, and plug in user source (localStorage, sessionStorage, API, or custom). * Supports full flexibility: frontend-only with storage or pluggable API. * * @param options - userSource, roleDefinitions, roleCapabilities, optional capabilitiesOverride * @returns user, roles, capabilities, hasRole, hasCapability, can, isReady, error, refetch, and storage helpers * * @example * ```tsx * const { hasRole, can, roles, setUserInStorage } = useRBAC({ * userSource: { type: 'localStorage', key: 'user' }, * roleDefinitions: [ * { role: 'admin', condition: (u) => u?.role === 'admin' }, * { role: 'editor', condition: (u) => u?.role === 'editor' || u?.role === 'admin' }, * ], * roleCapabilities: { * admin: ['*'], * editor: ['posts:edit', 'posts:create'], * }, * }); * if (can('posts:edit')) { ... } * ``` */ export function useRBAC(options: UseRBACOptions): UseRBACReturn { const { userSource } = options; const optsRef = useRef(options); optsRef.current = options; const [user, setUser] = useState<RBACUser | null>(null); const [roles, setRoles] = useState<string[]>([]); const [capabilities, setCapabilities] = useState<string[]>([]); const [isReady, setIsReady] = useState(false); const [error, setError] = useState<Error | null>(null); const resolveAuth = useCallback(async () => { const { userSource: us, roleDefinitions: rdefs, roleCapabilities: rcaps, capabilitiesOverride: capOver, } = optsRef.current; setError(null); let resolvedUser: RBACUser | null = null; let resolvedRoles: string[] = []; let resolvedCapabilities: string[] = []; try { if (us.type === "localStorage") { resolvedUser = parseUserFromStorage("localStorage", us.key); resolvedRoles = computeRoles(resolvedUser, rdefs); resolvedCapabilities = computeCapabilitiesFromRoles( resolvedRoles, rcaps ); } else if (us.type === "sessionStorage") { resolvedUser = parseUserFromStorage("sessionStorage", us.key); resolvedRoles = computeRoles(resolvedUser, rdefs); resolvedCapabilities = computeCapabilitiesFromRoles( resolvedRoles, rcaps ); } else if (us.type === "api") { resolvedUser = await us.fetch(); resolvedRoles = computeRoles(resolvedUser, rdefs); resolvedCapabilities = computeCapabilitiesFromRoles( resolvedRoles, rcaps ); } else if (us.type === "memory") { resolvedUser = us.getUser(); resolvedRoles = computeRoles(resolvedUser, rdefs); resolvedCapabilities = computeCapabilitiesFromRoles( resolvedRoles, rcaps ); } else if (us.type === "custom") { const auth = await Promise.resolve(us.getAuth()); resolvedUser = auth.user ?? null; resolvedRoles = auth.roles ?? computeRoles(resolvedUser, rdefs); resolvedCapabilities = auth.capabilities ?? computeCapabilitiesFromRoles(resolvedRoles, rcaps); } if (capOver) { if (capOver.type === "localStorage") { const override = parseCapabilitiesFromStorage( "localStorage", capOver.key ); if (override.length > 0) resolvedCapabilities = override; } else if (capOver.type === "sessionStorage") { const override = parseCapabilitiesFromStorage( "sessionStorage", capOver.key ); if (override.length > 0) resolvedCapabilities = override; } else if (capOver.type === "api") { const override = await capOver.fetch(); resolvedCapabilities = override; } } setUser(resolvedUser); setRoles(resolvedRoles); setCapabilities(resolvedCapabilities); } catch (e) { setError(e instanceof Error ? e : new Error(String(e))); setUser(null); setRoles([]); setCapabilities([]); } finally { setIsReady(true); } }, []); useEffect(() => { resolveAuth(); }, [resolveAuth]); // Listen to storage events when using localStorage/sessionStorage so we refetch when another tab changes data useEffect(() => { if (typeof window === "undefined") return; const key = userSource.type === "localStorage" || userSource.type === "sessionStorage" ? userSource.key : null; if (!key) return; const handler = (e: StorageEvent) => { if (e.key === key) void resolveAuth(); }; window.addEventListener("storage", handler); return () => window.removeEventListener("storage", handler); }, [ userSource.type, userSource.type === "localStorage" || userSource.type === "sessionStorage" ? (userSource as { key: string }).key : "", resolveAuth, ]); const hasRole = useCallback((role: string) => roles.includes(role), [roles]); const hasCapability = useCallback( (capability: string) => hasCapabilityImpl(capabilities, capability), [capabilities] ); const can = hasCapability; const refetch = useCallback(() => resolveAuth(), [resolveAuth]); const setUserInStorage = useCallback( ( newUser: RBACUser | null, storage: "localStorage" | "sessionStorage", key: string ) => { if (typeof window === "undefined") return; const s = storage === "localStorage" ? window.localStorage : window.sessionStorage; if (newUser == null) s.removeItem(key); else s.setItem(key, JSON.stringify(newUser)); if ( (userSource.type === "localStorage" && storage === "localStorage" && userSource.key === key) || (userSource.type === "sessionStorage" && storage === "sessionStorage" && userSource.key === key) ) { void resolveAuth(); } }, [userSource, resolveAuth] ); const setRolesInStorage = useCallback( ( newRoles: string[], storage: "localStorage" | "sessionStorage", key: string ) => { if (typeof window === "undefined") return; const s = storage === "localStorage" ? window.localStorage : window.sessionStorage; s.setItem(key, JSON.stringify(newRoles)); }, [] ); const setCapabilitiesInStorage = useCallback( ( newCaps: string[], storage: "localStorage" | "sessionStorage", key: string ) => { if (typeof window === "undefined") return; const s = storage === "localStorage" ? window.localStorage : window.sessionStorage; s.setItem(key, JSON.stringify(newCaps)); }, [] ); return { user, roles, capabilities, isReady, error, hasRole, hasCapability, can, refetch, setUserInStorage, setRolesInStorage, setCapabilitiesInStorage, }; }