slate-complete-1770512537/packages/auth/src/AuthProvider.tsx
jordan faa1561dc6
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/manual/woodpecker Pipeline was successful
Initialize project from skeleton template
2026-02-08 01:02:18 +00:00

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;
}