'use client'; import { createContext, useContext, useState, useCallback, useEffect, useRef, type ReactNode, } from 'react'; import { createApiClient } from '@bytelyst/api-client'; import type { AuthConfig, AuthContextValue, BaseUser } from './types.js'; /** * Create a typed auth provider + hook for a specific user type. * * Supports the full auth lifecycle: login, register, forgot password, * change password, delete account, and automatic token refresh. * * @example * ```tsx * const { AuthProvider, useAuth } = createAuthProvider({ * storagePrefix: "admin", * loginEndpoint: "/auth/login", * registerEndpoint: "/auth/register", * forgotPasswordEndpoint: "/auth/forgot-password", * changePasswordEndpoint: "/auth/change-password", * deleteAccountEndpoint: "/auth/delete-account", * refreshEndpoint: "/auth/refresh", * mapLoginResponse: (data) => ({ * user: data.user, * accessToken: data.accessToken, * refreshToken: data.refreshToken, * }), * }); * ``` */ export function createAuthProvider(config: AuthConfig) { const { baseUrl: configBaseUrl = '/api', storagePrefix, loginEndpoint, registerEndpoint, forgotPasswordEndpoint, changePasswordEndpoint, deleteAccountEndpoint, refreshEndpoint, refreshIntervalMs = 45 * 60 * 1000, mapLoginResponse, onLoginFallback, onInit, onLogout, } = config; const USER_KEY = `${storagePrefix}_auth_user`; const TOKEN_KEY = `${storagePrefix}_access_token`; const REFRESH_KEY = `${storagePrefix}_refresh_token`; const AuthContext = createContext | null>(null); function getStoredUser(): TUser | null { if (typeof window === 'undefined') return null; try { const stored = localStorage.getItem(USER_KEY); return stored ? JSON.parse(stored) : null; } catch { return null; } } function saveSession(user: TUser, accessToken: string, refreshToken: string) { localStorage.setItem(USER_KEY, JSON.stringify(user)); localStorage.setItem(TOKEN_KEY, accessToken); localStorage.setItem(REFRESH_KEY, refreshToken); } function clearSession() { localStorage.removeItem(USER_KEY); localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(REFRESH_KEY); } function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(() => { // Allow onInit to provide an initial session (e.g. from SSO cookies) if (onInit) { const initResult = onInit(); if (initResult) { saveSession(initResult.user, initResult.accessToken, initResult.refreshToken); return initResult.user; } } return getStoredUser(); }); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); const refreshTimerRef = useRef | null>(null); const api = createApiClient({ baseUrl: configBaseUrl, getToken: () => (typeof window !== 'undefined' ? localStorage.getItem(TOKEN_KEY) : null), }); const clearMessages = useCallback(() => { setError(null); setSuccess(null); }, []); // ── Token refresh ────────────────────────────── const refreshAccessToken = useCallback(async () => { if (!refreshEndpoint) return; const rt = typeof window !== 'undefined' ? localStorage.getItem(REFRESH_KEY) : null; if (!rt) return; try { const data = await api.fetch<{ accessToken: string; refreshToken: string }>( refreshEndpoint, { method: 'POST', body: JSON.stringify({ refreshToken: rt }) } ); localStorage.setItem(TOKEN_KEY, data.accessToken); localStorage.setItem(REFRESH_KEY, data.refreshToken); } catch { // Token expired — force logout setUser(null); clearSession(); onLogout?.(); } }, [api]); useEffect(() => { if (!user || !refreshEndpoint) return; refreshTimerRef.current = setInterval(refreshAccessToken, refreshIntervalMs); return () => { if (refreshTimerRef.current) clearInterval(refreshTimerRef.current); }; }, [user, refreshAccessToken, refreshIntervalMs]); // ── Login ────────────────────────────────────── const login = useCallback( async (email: string, password: string) => { setIsLoading(true); setError(null); try { const { data, error: fetchError } = await api.safeFetch(loginEndpoint, { method: 'POST', body: JSON.stringify({ email, password }), }); if (data && !fetchError) { const mapped = mapLoginResponse(data); setUser(mapped.user); saveSession(mapped.user, mapped.accessToken, mapped.refreshToken); return true; } if (fetchError && onLoginFallback) { const fallback = await onLoginFallback(email, password, fetchError); if (fallback) { setUser(fallback.user); saveSession(fallback.user, fallback.accessToken, fallback.refreshToken); return true; } } setError(fetchError || 'Login failed'); return false; } finally { setIsLoading(false); } }, [api] ); // ── Register ─────────────────────────────────── const register = useCallback( async (email: string, password: string, displayName: string) => { if (!registerEndpoint) { setError('Registration not supported'); return false; } setIsLoading(true); setError(null); try { const { data, error: fetchError } = await api.safeFetch(registerEndpoint, { method: 'POST', body: JSON.stringify({ email, password, displayName }), }); if (data && !fetchError) { const mapped = mapLoginResponse(data); setUser(mapped.user); saveSession(mapped.user, mapped.accessToken, mapped.refreshToken); return true; } setError(fetchError || 'Registration failed'); return false; } finally { setIsLoading(false); } }, [api] ); // ── Logout ───────────────────────────────────── const logout = useCallback(() => { setUser(null); clearSession(); if (refreshTimerRef.current) clearInterval(refreshTimerRef.current); onLogout?.(); }, []); // ── Forgot password ──────────────────────────── const forgotPassword = useCallback( async (email: string) => { if (!forgotPasswordEndpoint) { setError('Forgot password not supported'); return false; } setIsLoading(true); setError(null); setSuccess(null); try { const { error: fetchError } = await api.safeFetch<{ message: string }>( forgotPasswordEndpoint, { method: 'POST', body: JSON.stringify({ email }) } ); if (fetchError) { setError(fetchError); return false; } setSuccess('If that email exists, a reset link has been sent.'); return true; } finally { setIsLoading(false); } }, [api] ); // ── Change password ──────────────────────────── const changePassword = useCallback( async (currentPassword: string, newPassword: string) => { if (!changePasswordEndpoint) { setError('Change password not supported'); return false; } setIsLoading(true); setError(null); setSuccess(null); try { const { error: fetchError } = await api.safeFetch<{ message: string }>( changePasswordEndpoint, { method: 'POST', body: JSON.stringify({ currentPassword, newPassword }) } ); if (fetchError) { setError(fetchError); return false; } setSuccess('Password changed successfully.'); return true; } finally { setIsLoading(false); } }, [api] ); // ── Update user (local state + localStorage) ── const updateUser = useCallback((updates: Partial) => { setUser(prev => { if (!prev) return null; const updated = { ...prev, ...updates }; localStorage.setItem(USER_KEY, JSON.stringify(updated)); return updated; }); }, []); // ── Delete account ───────────────────────────── const deleteAccount = useCallback( async (password: string) => { if (!deleteAccountEndpoint) { setError('Account deletion not supported'); return false; } setIsLoading(true); setError(null); try { const { error: fetchError } = await api.safeFetch<{ message: string }>( deleteAccountEndpoint, { method: 'DELETE', body: JSON.stringify({ password }) } ); if (fetchError) { setError(fetchError); return false; } setUser(null); clearSession(); if (refreshTimerRef.current) clearInterval(refreshTimerRef.current); onLogout?.(); return true; } finally { setIsLoading(false); } }, [api] ); return ( {children} ); } function useAuth(): AuthContextValue { const ctx = useContext(AuthContext); if (!ctx) { throw new Error('useAuth must be used within an AuthProvider'); } return ctx; } return { AuthProvider, useAuth }; }