370 lines
9.2 KiB
TypeScript
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>;
|
|
}
|