platform-codebase/@packages/@providers/auth-provider/src/AuthProvider.tsx

370 lines
9.2 KiB
TypeScript

import { createContext, useEffect, useState, useCallback, ReactNode, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { SSOClient } from '@lilith/sso-client';
import { authEvents } from './auth-events';
import type {
AuthContextValue,
AuthState,
LoginCredentials,
RegisterData,
DirectRegisterData,
User,
DevAuthOverride,
} from './types';
interface AuthProviderProps {
children: ReactNode;
/**
* SSO service URL (required)
*/
ssoUrl: string;
/**
* Session check interval in ms (default: 300000 = 5 minutes)
*/
checkInterval?: number;
/**
* Popup width for login/register (default: 500)
*/
popupWidth?: number;
/**
* Popup height for login/register (default: 600)
*/
popupHeight?: number;
/**
* Dev mode override - when provided, bypasses real SSO auth.
* Only functional when import.meta.env.DEV is true.
*/
devOverride?: DevAuthOverride;
}
export const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function AuthProvider({
children,
ssoUrl,
checkInterval = 300000,
popupWidth = 500,
popupHeight = 600,
devOverride,
}: AuthProviderProps) {
const queryClient = useQueryClient();
const ssoClientRef = useRef<SSOClient | null>(null);
// Check if dev override is active (only in dev mode)
const isDevOverrideActive = import.meta.env.DEV && devOverride !== undefined;
const [authState, setAuthState] = useState<AuthState>({
user: null,
isLoading: !isDevOverrideActive, // No loading when dev override
isAuthenticated: false,
error: null,
isDevMode: false,
});
// Sync dev override to auth state
useEffect(() => {
if (isDevOverrideActive && devOverride) {
setAuthState({
user: devOverride.user,
isLoading: false,
isAuthenticated: devOverride.isAuthenticated,
error: null,
isDevMode: true,
});
// Also update query cache
queryClient.setQueryData(['auth', 'me'], devOverride.user);
}
}, [isDevOverrideActive, devOverride, queryClient]);
// Skip SSO client setup when dev override is active
useEffect(() => {
if (isDevOverrideActive) {
return; // Don't initialize SSO client in dev override mode
}
const ssoClient = new SSOClient({
ssoUrl,
checkInterval,
popupWidth,
popupHeight,
onAuthChange: (authenticated, user) => {
setAuthState({
user: user as User | null,
isLoading: false,
isAuthenticated: authenticated,
error: null,
});
if (authenticated && user) {
authEvents.broadcast({
type: 'login',
timestamp: Date.now(),
});
} else {
authEvents.broadcast({
type: 'logout',
timestamp: Date.now(),
});
}
queryClient.setQueryData(['auth', 'me'], user);
},
onError: (error) => {
setAuthState((prev) => ({
...prev,
error,
isLoading: false,
}));
},
});
ssoClientRef.current = ssoClient;
ssoClient.checkSession().then((response) => {
setAuthState({
user: response.user as User | null,
isLoading: false,
isAuthenticated: response.authenticated,
error: null,
});
if (response.user) {
queryClient.setQueryData(['auth', 'me'], response.user);
}
});
ssoClient.startAutoCheck();
authEvents.initialize();
const unsubscribe = authEvents.subscribe((event) => {
if (event.type === 'login' || event.type === 'token_refresh') {
ssoClient.checkSession();
} else if (event.type === 'logout') {
setAuthState({
user: null,
isLoading: false,
isAuthenticated: false,
error: null,
});
queryClient.setQueryData(['auth', 'me'], null);
queryClient.clear();
}
});
return () => {
ssoClient.destroy();
unsubscribe();
authEvents.destroy();
};
}, [ssoUrl, checkInterval, popupWidth, popupHeight, queryClient, isDevOverrideActive]);
const login = useCallback(
async (_credentials: LoginCredentials) => {
if (!ssoClientRef.current) {
throw new Error('SSO client not initialized');
}
setAuthState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
const user = await ssoClientRef.current.login();
setAuthState({
user: user as User,
isLoading: false,
isAuthenticated: true,
error: null,
});
queryClient.setQueryData(['auth', 'me'], user);
authEvents.broadcast({
type: 'login',
timestamp: Date.now(),
});
} catch (error) {
setAuthState((prev) => ({
...prev,
isLoading: false,
error: error as Error,
}));
throw error;
}
},
[queryClient]
);
const register = useCallback(
async (data: RegisterData) => {
if (!ssoClientRef.current) {
throw new Error('SSO client not initialized');
}
setAuthState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
const user = await ssoClientRef.current.register({ role: data.role });
setAuthState({
user: user as User,
isLoading: false,
isAuthenticated: true,
error: null,
});
queryClient.setQueryData(['auth', 'me'], user);
authEvents.broadcast({
type: 'login',
timestamp: Date.now(),
});
} catch (error) {
setAuthState((prev) => ({
...prev,
isLoading: false,
error: error as Error,
}));
throw error;
}
},
[queryClient]
);
const loginWithCredentials = useCallback(
async (email: string, password: string) => {
if (!ssoClientRef.current) {
throw new Error('SSO client not initialized');
}
setAuthState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
const result = await ssoClientRef.current.loginWithCredentials(email, password);
if (result.mfaRequired) {
// MFA will be handled by the SSOClient's onMfaRequired callback
setAuthState((prev) => ({ ...prev, isLoading: false }));
return;
}
setAuthState({
user: result.user as User,
isLoading: false,
isAuthenticated: true,
error: null,
});
queryClient.setQueryData(['auth', 'me'], result.user);
authEvents.broadcast({
type: 'login',
timestamp: Date.now(),
});
} catch (error) {
setAuthState((prev) => ({
...prev,
isLoading: false,
error: error as Error,
}));
throw error;
}
},
[queryClient]
);
const registerWithCredentials = useCallback(
async (data: DirectRegisterData) => {
if (!ssoClientRef.current) {
throw new Error('SSO client not initialized');
}
setAuthState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
const result = await ssoClientRef.current.registerWithCredentials(
data.email,
data.username,
data.password,
data.role
);
setAuthState({
user: result.user as User,
isLoading: false,
isAuthenticated: true,
error: null,
});
queryClient.setQueryData(['auth', 'me'], result.user);
authEvents.broadcast({
type: 'login',
timestamp: Date.now(),
});
} catch (error) {
setAuthState((prev) => ({
...prev,
isLoading: false,
error: error as Error,
}));
throw error;
}
},
[queryClient]
);
const logout = useCallback(async () => {
if (!ssoClientRef.current) {
throw new Error('SSO client not initialized');
}
setAuthState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
await ssoClientRef.current.logout();
setAuthState({
user: null,
isLoading: false,
isAuthenticated: false,
error: null,
});
queryClient.setQueryData(['auth', 'me'], null);
queryClient.clear();
authEvents.broadcast({
type: 'logout',
timestamp: Date.now(),
});
} catch (error) {
setAuthState((prev) => ({
...prev,
isLoading: false,
error: error as Error,
}));
throw error;
}
}, [queryClient]);
const refreshAuth = useCallback(async () => {
if (!ssoClientRef.current) {
throw new Error('SSO client not initialized');
}
const response = await ssoClientRef.current.checkSession();
setAuthState({
user: response.user as User | null,
isLoading: false,
isAuthenticated: response.authenticated,
error: null,
});
if (response.user) {
queryClient.setQueryData(['auth', 'me'], response.user);
}
}, [queryClient]);
const contextValue: AuthContextValue = {
...authState,
login,
register,
loginWithCredentials,
registerWithCredentials,
logout,
refreshAuth,
};
return <AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>;
}