stage 1 of oauth removal, switch to sanctum pat tokens
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
import React from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
authorizedFetch,
|
||||
clearOAuthSession,
|
||||
clearTokens,
|
||||
completeOAuthCallback,
|
||||
loadTokens,
|
||||
isAuthError,
|
||||
loadToken,
|
||||
registerAuthFailureHandler,
|
||||
startOAuthFlow,
|
||||
storePersonalAccessToken,
|
||||
} from './tokens';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_LOGIN_PATH } from '../constants';
|
||||
import { invalidateTenantApiCache } from '../api';
|
||||
|
||||
export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
|
||||
|
||||
@@ -18,30 +18,74 @@ export interface TenantProfile {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
email?: string | null;
|
||||
event_credits_balance?: number;
|
||||
event_credits_balance?: number | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
status: AuthStatus;
|
||||
user: TenantProfile | null;
|
||||
login: (redirectPath?: string) => void;
|
||||
logout: (options?: { redirect?: string }) => void;
|
||||
completeLogin: (params: URLSearchParams) => Promise<string | 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.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);
|
||||
@@ -49,92 +93,133 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}, [handleAuthFailure]);
|
||||
|
||||
const refreshProfile = React.useCallback(async () => {
|
||||
setStatus('loading');
|
||||
|
||||
try {
|
||||
const response = await authorizedFetch('/api/v1/tenant/me');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load profile');
|
||||
}
|
||||
const profile = (await response.json()) as TenantProfile;
|
||||
setUser(profile);
|
||||
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);
|
||||
handleAuthFailure();
|
||||
|
||||
if (isAuthError(error)) {
|
||||
handleAuthFailure();
|
||||
} else {
|
||||
setStatus('unauthenticated');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}, [handleAuthFailure]);
|
||||
}, [handleAuthFailure, profileQueryKey, queryClient]);
|
||||
|
||||
const applyToken = React.useCallback(async (token: string, abilities: string[]) => {
|
||||
storePersonalAccessToken(token, abilities);
|
||||
await refreshProfile();
|
||||
}, [refreshProfile]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
if (searchParams.has('reset-auth') || window.location.pathname === ADMIN_LOGIN_PATH) {
|
||||
clearTokens();
|
||||
clearOAuthSession();
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
}
|
||||
let cancelled = false;
|
||||
|
||||
const tokens = loadTokens();
|
||||
if (!tokens) {
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refreshProfile().catch(() => {
|
||||
// refreshProfile already handled failures.
|
||||
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();
|
||||
});
|
||||
}, [handleAuthFailure, refreshProfile]);
|
||||
|
||||
const login = React.useCallback((redirectPath?: string) => {
|
||||
const sanitizedTarget = redirectPath && redirectPath.trim() !== '' ? redirectPath : ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
const target = sanitizedTarget.startsWith('/') ? sanitizedTarget : `/${sanitizedTarget}`;
|
||||
startOAuthFlow(target);
|
||||
}, []);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [applyToken, handleAuthFailure, refreshProfile]);
|
||||
|
||||
const logout = React.useCallback(async ({ redirect }: { redirect?: string } = {}) => {
|
||||
try {
|
||||
const csrf = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content;
|
||||
await fetch('/logout', {
|
||||
await authorizedFetch('/api/v1/tenant-auth/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] Failed to notify backend about logout', error);
|
||||
console.warn('[Auth] API logout failed', error);
|
||||
}
|
||||
} finally {
|
||||
clearTokens();
|
||||
clearOAuthSession();
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
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;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const completeLogin = React.useCallback(
|
||||
async (params: URLSearchParams) => {
|
||||
setStatus('loading');
|
||||
try {
|
||||
const redirectTarget = await completeOAuthCallback(params);
|
||||
await refreshProfile();
|
||||
return redirectTarget;
|
||||
} catch (error) {
|
||||
handleAuthFailure();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[handleAuthFailure, refreshProfile]
|
||||
);
|
||||
}, [handleAuthFailure]);
|
||||
|
||||
const value = React.useMemo<AuthContextValue>(
|
||||
() => ({ status, user, login, logout, completeLogin, refreshProfile }),
|
||||
[status, user, login, logout, completeLogin, refreshProfile]
|
||||
() => ({ status, user, refreshProfile, logout, applyToken }),
|
||||
[status, user, refreshProfile, logout, applyToken]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
Reference in New Issue
Block a user