127 lines
3.4 KiB
TypeScript
127 lines
3.4 KiB
TypeScript
import React from 'react';
|
|
import {
|
|
authorizedFetch,
|
|
clearOAuthSession,
|
|
clearTokens,
|
|
completeOAuthCallback,
|
|
isAuthError,
|
|
loadTokens,
|
|
registerAuthFailureHandler,
|
|
startOAuthFlow,
|
|
} from './tokens';
|
|
|
|
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;
|
|
[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>;
|
|
}
|
|
|
|
const AuthContext = React.createContext<AuthContextValue | undefined>(undefined);
|
|
|
|
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 handleAuthFailure = React.useCallback(() => {
|
|
clearTokens();
|
|
setUser(null);
|
|
setStatus('unauthenticated');
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
const unsubscribe = registerAuthFailureHandler(handleAuthFailure);
|
|
return unsubscribe;
|
|
}, [handleAuthFailure]);
|
|
|
|
const refreshProfile = React.useCallback(async () => {
|
|
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);
|
|
setStatus('authenticated');
|
|
} catch (error) {
|
|
if (isAuthError(error)) {
|
|
handleAuthFailure();
|
|
} else {
|
|
console.error('[Auth] Failed to refresh profile', error);
|
|
}
|
|
throw error;
|
|
}
|
|
}, [handleAuthFailure]);
|
|
|
|
React.useEffect(() => {
|
|
const tokens = loadTokens();
|
|
if (!tokens) {
|
|
setStatus('unauthenticated');
|
|
return;
|
|
}
|
|
|
|
refreshProfile().catch(() => {
|
|
// refreshProfile already handled failures.
|
|
});
|
|
}, [refreshProfile]);
|
|
|
|
const login = React.useCallback((redirectPath?: string) => {
|
|
const target = redirectPath ?? window.location.pathname + window.location.search;
|
|
startOAuthFlow(target);
|
|
}, []);
|
|
|
|
const logout = React.useCallback(({ redirect }: { redirect?: string } = {}) => {
|
|
clearTokens();
|
|
clearOAuthSession();
|
|
setUser(null);
|
|
setStatus('unauthenticated');
|
|
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]
|
|
);
|
|
|
|
const value = React.useMemo<AuthContextValue>(
|
|
() => ({ status, user, login, logout, completeLogin, refreshProfile }),
|
|
[status, user, login, logout, completeLogin, refreshProfile]
|
|
);
|
|
|
|
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;
|
|
}
|