Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
88 lines
2.7 KiB
TypeScript
88 lines
2.7 KiB
TypeScript
import { useSyncExternalStore } from "react";
|
|
import type { AuthenticatedUser } from "../api/types";
|
|
import { queryClient } from "../app/queryClient";
|
|
import { useSessionStore } from "../store/sessionStore";
|
|
import { rememberSession } from "../shell/host/rememberedSession";
|
|
|
|
let current: AuthenticatedUser | null = null;
|
|
let generation = 0;
|
|
const listeners = new Set<() => void>();
|
|
|
|
function notify() {
|
|
for (const listener of listeners) listener();
|
|
}
|
|
|
|
function scrubUserBoundState(): void {
|
|
queryClient.clear();
|
|
useSessionStore.getState().resetSession();
|
|
}
|
|
|
|
/** Authentication is intentionally process-local; no browser storage is involved. */
|
|
export function getAuthState(): AuthenticatedUser | null {
|
|
return current;
|
|
}
|
|
|
|
export function setAuthState(user: AuthenticatedUser): void {
|
|
if (current && (current.issuer !== user.issuer || current.subject !== user.subject)) rememberSession(current, null);
|
|
scrubUserBoundState();
|
|
current = user;
|
|
generation += 1;
|
|
notify();
|
|
}
|
|
|
|
/** Refresh expiry/CSRF metadata without discarding the current user's work. */
|
|
export function refreshAuthState(user: AuthenticatedUser): void {
|
|
if (!current || current.issuer !== user.issuer || current.subject !== user.subject
|
|
|| current.isAdmin !== user.isAdmin
|
|
|| JSON.stringify([...current.permissions].sort()) !== JSON.stringify([...user.permissions].sort())
|
|
|| JSON.stringify([...current.roles].sort()) !== JSON.stringify([...user.roles].sort())) {
|
|
setAuthState(user);
|
|
return;
|
|
}
|
|
current = user;
|
|
notify();
|
|
}
|
|
|
|
export function clearAuthState(): void {
|
|
if (current) rememberSession(current, null);
|
|
scrubUserBoundState();
|
|
current = null;
|
|
generation += 1;
|
|
notify();
|
|
}
|
|
|
|
export function isAuthGenerationCurrent(expectedGeneration: number): boolean {
|
|
return generation === expectedGeneration;
|
|
}
|
|
|
|
export function clearAuthStateIfCurrent(expectedGeneration: number): boolean {
|
|
if (!isAuthGenerationCurrent(expectedGeneration)) return false;
|
|
clearAuthState();
|
|
return true;
|
|
}
|
|
|
|
export function getAuthGeneration(): number {
|
|
return generation;
|
|
}
|
|
|
|
export function subscribeAuthState(listener: () => void): () => void {
|
|
listeners.add(listener);
|
|
return () => listeners.delete(listener);
|
|
}
|
|
|
|
export function useAuthState(): AuthenticatedUser | null {
|
|
return useSyncExternalStore(subscribeAuthState, getAuthState, getAuthState);
|
|
}
|
|
|
|
export function useAuthUser(): AuthenticatedUser | null {
|
|
return useAuthState();
|
|
}
|
|
|
|
export function useAuthGeneration(): number {
|
|
return useSyncExternalStore(subscribeAuthState, getAuthGeneration, getAuthGeneration);
|
|
}
|
|
|
|
export function hasPermission(user: Pick<AuthenticatedUser, "permissions"> | null | undefined, permission: string): boolean {
|
|
return user?.permissions.includes(permission) ?? false;
|
|
}
|