Files
fotospiel-app/resources/js/admin/auth/context.tsx
2025-11-07 13:52:29 +01:00

239 lines
6.3 KiB
TypeScript

import React from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
authorizedFetch,
clearTokens,
isAuthError,
loadToken,
registerAuthFailureHandler,
storePersonalAccessToken,
} from './tokens';
import { invalidateTenantApiCache } from '../api';
export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
export interface TenantProfile {
id: number;
tenant_id: number;
name?: string;
slug?: string;
email?: string | null;
event_credits_balance?: number | null;
[key: string]: unknown;
}
interface AuthContextValue {
status: AuthStatus;
user: TenantProfile | null;
refreshProfile: () => Promise<void>;
logout: (options?: { redirect?: string }) => Promise<void>;
applyToken: (token: string, abilities: string[]) => Promise<void>;
}
const AuthContext = React.createContext<AuthContextValue | undefined>(undefined);
function getCsrfToken(): string | undefined {
const meta = document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null;
return meta?.content;
}
async function exchangeSessionForToken(): Promise<{ token: string; abilities: string[] } | null> {
const csrf = getCsrfToken();
try {
const response = await fetch('/api/v1/tenant-auth/exchange', {
method: 'POST',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
},
credentials: 'same-origin',
});
if (response.status === 204) {
return null;
}
if (!response.ok) {
return null;
}
const data = (await response.json()) as { token: string; abilities?: string[] };
if (!data?.token) {
return null;
}
return {
token: data.token,
abilities: Array.isArray(data.abilities) ? data.abilities : [],
};
} catch (error) {
if (import.meta.env.DEV) {
console.warn('[Auth] Session exchange failed', error);
}
return null;
}
}
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [status, setStatus] = React.useState<AuthStatus>('loading');
const [user, setUser] = React.useState<TenantProfile | null>(null);
const queryClient = useQueryClient();
const profileQueryKey = React.useMemo(() => ['tenantProfile'], []);
const handleAuthFailure = React.useCallback(() => {
clearTokens();
invalidateTenantApiCache();
queryClient.removeQueries({ queryKey: profileQueryKey });
setUser(null);
setStatus('unauthenticated');
}, [profileQueryKey, queryClient]);
React.useEffect(() => {
const unsubscribe = registerAuthFailureHandler(handleAuthFailure);
return unsubscribe;
}, [handleAuthFailure]);
const refreshProfile = React.useCallback(async () => {
setStatus('loading');
try {
const data = await queryClient.fetchQuery({
queryKey: profileQueryKey,
queryFn: async () => {
const response = await authorizedFetch('/api/v1/tenant-auth/me', {
method: 'GET',
});
if (!response.ok) {
throw new Error('Failed to fetch tenant profile');
}
return (await response.json()) as {
user: TenantProfile | null;
tenant?: Record<string, unknown> | null;
abilities: string[];
};
},
staleTime: 1000 * 60 * 5,
cacheTime: 1000 * 60 * 30,
retry: 1,
});
const composed: TenantProfile | null = data.user && data.tenant
? { ...data.user, ...data.tenant }
: data.user;
setUser(composed ?? null);
setStatus('authenticated');
} catch (error) {
console.error('[Auth] Failed to refresh profile', error);
if (isAuthError(error)) {
handleAuthFailure();
} else {
setStatus('unauthenticated');
}
throw error;
}
}, [handleAuthFailure, profileQueryKey, queryClient]);
const applyToken = React.useCallback(async (token: string, abilities: string[]) => {
storePersonalAccessToken(token, abilities);
await refreshProfile();
}, [refreshProfile]);
React.useEffect(() => {
let cancelled = false;
const bootstrap = async () => {
const stored = loadToken();
if (stored) {
try {
await refreshProfile();
return;
} catch (error) {
if (import.meta.env.DEV) {
console.warn('[Auth] Stored token bootstrap failed', error);
}
}
}
const exchanged = await exchangeSessionForToken();
if (cancelled) {
return;
}
if (exchanged) {
await applyToken(exchanged.token, exchanged.abilities);
return;
}
handleAuthFailure();
};
bootstrap().catch((error) => {
if (import.meta.env.DEV) {
console.error('[Auth] Failed to bootstrap authentication', error);
}
handleAuthFailure();
});
return () => {
cancelled = true;
};
}, [applyToken, handleAuthFailure, refreshProfile]);
const logout = React.useCallback(async ({ redirect }: { redirect?: string } = {}) => {
try {
await authorizedFetch('/api/v1/tenant-auth/logout', {
method: 'POST',
});
} catch (error) {
if (import.meta.env.DEV) {
console.warn('[Auth] API logout failed', error);
}
} finally {
try {
const csrf = getCsrfToken();
await fetch('/logout', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
},
credentials: 'same-origin',
});
} catch (error) {
if (import.meta.env.DEV) {
console.warn('[Auth] Session logout failed', error);
}
}
handleAuthFailure();
if (redirect) {
window.location.href = redirect;
}
}
}, [handleAuthFailure]);
const value = React.useMemo<AuthContextValue>(
() => ({ status, user, refreshProfile, logout, applyToken }),
[status, user, refreshProfile, logout, applyToken]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export function useAuth(): AuthContextValue {
const context = React.useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}