287 lines
7.2 KiB
TypeScript
287 lines
7.2 KiB
TypeScript
import * as React from 'react';
|
|
import { createContext, useContext, useCallback, useMemo, useEffect, useState } from 'react';
|
|
import type { User, AuthState, LoginCredentials } from './types';
|
|
|
|
const TOKEN_STORAGE_KEY = 'auth_token';
|
|
const USER_STORAGE_KEY = 'auth_user';
|
|
|
|
/**
|
|
* Authentication context value.
|
|
*/
|
|
export interface AuthContextValue extends AuthState {
|
|
/** Log in with credentials */
|
|
login: (credentials: LoginCredentials) => Promise<void>;
|
|
/** Log in with a token directly */
|
|
loginWithToken: (token: string, user?: User) => void;
|
|
/** Log out the current user */
|
|
logout: () => void;
|
|
/** Get the current access token */
|
|
getToken: () => string | null;
|
|
/** Check if user has a specific role */
|
|
hasRole: (role: string) => boolean;
|
|
/** Check if user has a specific scope */
|
|
hasScope: (scope: string) => boolean;
|
|
}
|
|
|
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
|
|
/**
|
|
* Auth provider configuration.
|
|
*/
|
|
export interface AuthProviderProps {
|
|
children: React.ReactNode;
|
|
/** API endpoint for login */
|
|
loginUrl?: string;
|
|
/** API endpoint for logout */
|
|
logoutUrl?: string;
|
|
/** API endpoint for fetching current user */
|
|
userUrl?: string;
|
|
/** Custom login handler */
|
|
onLogin?: (credentials: LoginCredentials) => Promise<{ token: string; user: User }>;
|
|
/** Custom logout handler */
|
|
onLogout?: () => Promise<void>;
|
|
/** Storage type for persisting auth state */
|
|
storage?: 'localStorage' | 'sessionStorage' | 'none';
|
|
}
|
|
|
|
/**
|
|
* AuthProvider manages authentication state and provides auth methods.
|
|
*
|
|
* @example
|
|
* // Basic usage
|
|
* <AuthProvider loginUrl="/api/auth/login">
|
|
* <App />
|
|
* </AuthProvider>
|
|
*
|
|
* @example
|
|
* // With custom handlers
|
|
* <AuthProvider
|
|
* onLogin={async (creds) => {
|
|
* const res = await myAuthService.login(creds);
|
|
* return { token: res.token, user: res.user };
|
|
* }}
|
|
* >
|
|
* <App />
|
|
* </AuthProvider>
|
|
*/
|
|
export function AuthProvider({
|
|
children,
|
|
loginUrl = '/api/auth/login',
|
|
logoutUrl = '/api/auth/logout',
|
|
userUrl = '/api/auth/me',
|
|
onLogin,
|
|
onLogout,
|
|
storage = 'localStorage',
|
|
}: AuthProviderProps) {
|
|
const [state, setState] = useState<AuthState>({
|
|
user: null,
|
|
isLoading: true,
|
|
isAuthenticated: false,
|
|
error: null,
|
|
});
|
|
|
|
// Get storage implementation
|
|
const getStorage = useCallback(() => {
|
|
if (storage === 'none') return null;
|
|
return storage === 'sessionStorage' ? sessionStorage : localStorage;
|
|
}, [storage]);
|
|
|
|
// Initialize auth state from storage
|
|
useEffect(() => {
|
|
const store = getStorage();
|
|
if (!store) {
|
|
setState((s) => ({ ...s, isLoading: false }));
|
|
return;
|
|
}
|
|
|
|
const token = store.getItem(TOKEN_STORAGE_KEY);
|
|
const userJson = store.getItem(USER_STORAGE_KEY);
|
|
|
|
if (token && userJson) {
|
|
try {
|
|
const user = JSON.parse(userJson) as User;
|
|
setState({
|
|
user,
|
|
isLoading: false,
|
|
isAuthenticated: true,
|
|
error: null,
|
|
});
|
|
} catch {
|
|
// Invalid stored data, clear it
|
|
store.removeItem(TOKEN_STORAGE_KEY);
|
|
store.removeItem(USER_STORAGE_KEY);
|
|
setState((s) => ({ ...s, isLoading: false }));
|
|
}
|
|
} else {
|
|
setState((s) => ({ ...s, isLoading: false }));
|
|
}
|
|
}, [getStorage]);
|
|
|
|
// Login with credentials
|
|
const login = useCallback(
|
|
async (credentials: LoginCredentials) => {
|
|
setState((s) => ({ ...s, isLoading: true, error: null }));
|
|
|
|
try {
|
|
let token: string;
|
|
let user: User;
|
|
|
|
if (onLogin) {
|
|
// Use custom login handler
|
|
const result = await onLogin(credentials);
|
|
token = result.token;
|
|
user = result.user;
|
|
} else {
|
|
// Use default API login
|
|
const response = await fetch(loginUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(credentials),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.message || 'Login failed');
|
|
}
|
|
|
|
const data = await response.json();
|
|
token = data.data?.token || data.token;
|
|
user = data.data?.user || data.user;
|
|
}
|
|
|
|
// Store token and user
|
|
const store = getStorage();
|
|
if (store) {
|
|
store.setItem(TOKEN_STORAGE_KEY, token);
|
|
store.setItem(USER_STORAGE_KEY, JSON.stringify(user));
|
|
}
|
|
|
|
setState({
|
|
user,
|
|
isLoading: false,
|
|
isAuthenticated: true,
|
|
error: null,
|
|
});
|
|
} catch (error) {
|
|
setState({
|
|
user: null,
|
|
isLoading: false,
|
|
isAuthenticated: false,
|
|
error: error instanceof Error ? error : new Error('Login failed'),
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
[loginUrl, onLogin, getStorage]
|
|
);
|
|
|
|
// Login with token directly
|
|
const loginWithToken = useCallback(
|
|
(token: string, user?: User) => {
|
|
const store = getStorage();
|
|
if (store) {
|
|
store.setItem(TOKEN_STORAGE_KEY, token);
|
|
if (user) {
|
|
store.setItem(USER_STORAGE_KEY, JSON.stringify(user));
|
|
}
|
|
}
|
|
|
|
setState({
|
|
user: user || null,
|
|
isLoading: false,
|
|
isAuthenticated: true,
|
|
error: null,
|
|
});
|
|
},
|
|
[getStorage]
|
|
);
|
|
|
|
// Logout
|
|
const logout = useCallback(async () => {
|
|
try {
|
|
if (onLogout) {
|
|
await onLogout();
|
|
} else if (logoutUrl) {
|
|
await fetch(logoutUrl, { method: 'POST' }).catch(() => {});
|
|
}
|
|
} finally {
|
|
const store = getStorage();
|
|
if (store) {
|
|
store.removeItem(TOKEN_STORAGE_KEY);
|
|
store.removeItem(USER_STORAGE_KEY);
|
|
}
|
|
|
|
setState({
|
|
user: null,
|
|
isLoading: false,
|
|
isAuthenticated: false,
|
|
error: null,
|
|
});
|
|
}
|
|
}, [logoutUrl, onLogout, getStorage]);
|
|
|
|
// Get token
|
|
const getToken = useCallback(() => {
|
|
const store = getStorage();
|
|
return store ? store.getItem(TOKEN_STORAGE_KEY) : null;
|
|
}, [getStorage]);
|
|
|
|
// Role check
|
|
const hasRole = useCallback(
|
|
(role: string) => {
|
|
return state.user?.roles?.includes(role) ?? false;
|
|
},
|
|
[state.user]
|
|
);
|
|
|
|
// Scope check
|
|
const hasScope = useCallback(
|
|
(scope: string) => {
|
|
return state.user?.scopes?.includes(scope) ?? false;
|
|
},
|
|
[state.user]
|
|
);
|
|
|
|
const value = useMemo(
|
|
(): AuthContextValue => ({
|
|
...state,
|
|
login,
|
|
loginWithToken,
|
|
logout,
|
|
getToken,
|
|
hasRole,
|
|
hasScope,
|
|
}),
|
|
[state, login, loginWithToken, logout, getToken, hasRole, hasScope]
|
|
);
|
|
|
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
}
|
|
|
|
/**
|
|
* Hook to access authentication state and methods.
|
|
*
|
|
* @example
|
|
* function Profile() {
|
|
* const { user, logout, isAuthenticated } = useAuth();
|
|
*
|
|
* if (!isAuthenticated) {
|
|
* return <LoginForm />;
|
|
* }
|
|
*
|
|
* return (
|
|
* <div>
|
|
* <p>Welcome, {user?.name}</p>
|
|
* <button onClick={logout}>Logout</button>
|
|
* </div>
|
|
* );
|
|
* }
|
|
*/
|
|
export function useAuth(): AuthContextValue {
|
|
const context = useContext(AuthContext);
|
|
if (!context) {
|
|
throw new Error('useAuth must be used within an AuthProvider');
|
|
}
|
|
return context;
|
|
}
|