import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; // ============================================================================= // Types (no roles - all users are equal) // ============================================================================= export interface User { id: number; username: string; avatarUrl?: string | null; } export interface AuthState { authEnabled: boolean; registrationEnabled: boolean; localAuthEnabled: boolean; oidcEnabled: boolean; oidcProviderName: string; hasUsers: boolean; needsSetup: boolean; } interface AuthContextType { user: User | null; authState: AuthState | null; loading: boolean; authError: string | null; login: (username: string, password: string, rememberMe?: boolean) => Promise; register: (username: string, password: string) => Promise; logout: () => Promise; refreshUser: () => Promise; updateProfile: (data: { currentPassword?: string; newPassword?: string }) => Promise; uploadAvatar: (file: File) => Promise; deleteAvatar: () => Promise; authFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; } // ============================================================================= // Context // ============================================================================= const AuthContext = createContext(null); export function useAuth() { const context = useContext(AuthContext); if (!context) { throw new Error("useAuth must be used within AuthProvider"); } return context; } // ============================================================================= // Provider // ============================================================================= export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [authState, setAuthState] = useState(null); const [loading, setLoading] = useState(true); const [authError, setAuthError] = useState(null); // Track if initial fetch has been done to prevent duplicate calls const initialFetchDone = useRef(false); // Fetch auth state on mount (only once) useEffect(() => { if (initialFetchDone.current) return; initialFetchDone.current = true; fetchAuthState(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Proactively refresh token every 10 minutes to prevent expiration useEffect(() => { if (!user || !authState?.authEnabled) return; const refreshInterval = setInterval( async () => { const success = await tryRefreshToken(); if (!success) { // Refresh failed - check if user is still valid await refreshUser(); } }, 10 * 60 * 1000 ); // 10 minutes (before 15 min access token expires) return () => clearInterval(refreshInterval); // eslint-disable-next-line react-hooks/exhaustive-deps }, [user, authState?.authEnabled]); async function fetchAuthState(retryCount = 0) { const maxRetries = 3; const retryDelay = 1000; // 1 second try { setAuthError(null); const res = await fetch("/api/auth/state"); if (!res.ok) { throw new Error(`Server error: ${res.status}`); } const state = await res.json(); setAuthState(state); // If auth is enabled and we might be logged in, check session if (state.authEnabled) { await refreshUser(); } setLoading(false); } catch (err) { console.error(`Failed to fetch auth state (attempt ${retryCount + 1}/${maxRetries + 1}):`, err); // Retry on connection errors or 5xx errors (server might be restarting) if (retryCount < maxRetries) { await new Promise((resolve) => setTimeout(resolve, retryDelay)); return fetchAuthState(retryCount + 1); } setAuthError(err instanceof Error ? err.message : "Failed to connect to server"); setLoading(false); } } async function refreshUser() { try { const res = await fetch("/api/auth/me", { credentials: "include" }); if (res.ok) { const userData = await res.json(); setUser(userData); } else if (res.status === 401) { // Access token expired - try to refresh it const refreshed = await tryRefreshToken(); if (refreshed) { // Retry /auth/me with new token const retryRes = await fetch("/api/auth/me", { credentials: "include" }); if (retryRes.ok) { const userData = await retryRes.json(); setUser(userData); return; } } setUser(null); } else { setUser(null); } } catch { setUser(null); } } // Try to refresh the access token using the refresh token async function tryRefreshToken(): Promise { try { const res = await fetch("/api/auth/refresh", { method: "POST", credentials: "include", }); return res.ok; } catch { return false; } } async function login(username: string, password: string, rememberMe: boolean = false) { const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ username, password, rememberMe }), }); if (!res.ok) { const data = await res.json(); throw new Error(data.error || "Login failed"); } const data = await res.json(); setUser(data.user); } async function register(username: string, password: string) { const res = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ username, password }), }); if (!res.ok) { const data = await res.json(); throw new Error(data.error || "Registration failed"); } // Auto-login after registration await login(username, password); // Refresh auth state (registration might disable further registrations) await fetchAuthState(); } async function logout() { await fetch("/api/auth/logout", { method: "POST", credentials: "include", }); setUser(null); } async function updateProfile(data: { currentPassword?: string; newPassword?: string }) { const res = await fetch("/api/auth/me", { method: "PUT", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(data), }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || "Update failed"); } await refreshUser(); } // Upload avatar async function uploadAvatar(file: File) { const formData = new FormData(); formData.append("file", file); const res = await fetch("/api/auth/avatar", { method: "POST", credentials: "include", body: formData, }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Upload failed" })); throw new Error(err.error || "Upload failed"); } await refreshUser(); } // Delete avatar async function deleteAvatar() { const res = await fetch("/api/auth/avatar", { method: "DELETE", credentials: "include", }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Delete failed" })); throw new Error(err.error || "Delete failed"); } await refreshUser(); } // Fetch wrapper that automatically refreshes token on 401 const authFetch = useCallback( async (input: RequestInfo | URL, init?: RequestInit): Promise => { const options: RequestInit = { ...init, credentials: "include", }; let res = await fetch(input, options); // If 401 and not already a refresh/login request, try to refresh token if (res.status === 401 && !String(input).includes("/auth/")) { const refreshed = await tryRefreshToken(); if (refreshed) { // Retry the original request with new token res = await fetch(input, options); } else { // Refresh failed - user needs to login again setUser(null); } } return res; }, [tryRefreshToken] ); return ( {children} ); } // ============================================================================= // Login Form // ============================================================================= export function LoginForm({ onSuccess, onSwitchToRegister, }: { onSuccess?: () => void; onSwitchToRegister?: () => void; }) { const { t } = useTranslation(); const { login, authState } = useAuth(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [rememberMe, setRememberMe] = useState(false); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); setLoading(true); try { await login(username, password, rememberMe); onSuccess?.(); } catch (err) { setError(err instanceof Error ? err.message : "Login failed"); } finally { setLoading(false); } } return (

💊 MedAssist

{t("auth.login", "Login")}

{/* SSO Login Button */} {authState?.oidcEnabled && (
{authState?.localAuthEnabled && (
{t("auth.or", "or")}
)}
)} {/* Local Login Form - only show if local auth is enabled */} {authState?.localAuthEnabled && (
{error &&
{error}
}
setUsername(e.target.value)} required autoComplete="username" />
setPassword(e.target.value)} required autoComplete="current-password" />
)} {authState?.registrationEnabled && authState?.localAuthEnabled && onSwitchToRegister && (
)}
); } // ============================================================================= // Registration Form // ============================================================================= export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () => void; onSwitchToLogin?: () => void }) { const { t } = useTranslation(); const { register, authState } = useAuth(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); if (password !== confirmPassword) { setError(t("auth.passwordMismatch", "Passwords do not match")); return; } setLoading(true); try { await register(username, password); onSuccess?.(); } catch (err) { setError(err instanceof Error ? err.message : "Registration failed"); } finally { setLoading(false); } } return (

💊 MedAssist

{t("auth.register", "Create Account")}

{/* SSO Login Button - also show on registration */} {authState?.oidcEnabled && (
{authState?.localAuthEnabled && (
{t("auth.or", "or")}
)}
)} {/* Local Registration Form - only show if local auth is enabled */} {authState?.localAuthEnabled && (
{error &&
{error}
}
setUsername(e.target.value)} required autoComplete="username" minLength={3} maxLength={50} pattern="[a-zA-Z0-9_-]+" title={t("auth.usernameHint", "Letters, numbers, underscores, and hyphens only")} />
setPassword(e.target.value)} required autoComplete="new-password" minLength={8} maxLength={128} />
setConfirmPassword(e.target.value)} required autoComplete="new-password" />
)} {onSwitchToLogin && (
)}
); } // ============================================================================= // User Profile Component // ============================================================================= export function UserProfile({ onClose }: { onClose?: () => void }) { const { t } = useTranslation(); const { user, updateProfile, uploadAvatar, deleteAvatar } = useAuth(); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); const [loading, setLoading] = useState(false); const [avatarLoading, setAvatarLoading] = useState(false); const fileInputRef = useRef(null); // Close on Escape key useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape" && onClose) { onClose(); } }; document.addEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape); }, [onClose]); async function handleAvatarUpload(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; setAvatarLoading(true); setError(""); try { await uploadAvatar(file); setSuccess(t("auth.avatarUpdated", "Avatar updated")); } catch (err) { setError(err instanceof Error ? err.message : "Upload failed"); } finally { setAvatarLoading(false); if (fileInputRef.current) fileInputRef.current.value = ""; } } async function handleAvatarDelete() { setAvatarLoading(true); setError(""); try { await deleteAvatar(); setSuccess(t("auth.avatarRemoved", "Avatar removed")); } catch (err) { setError(err instanceof Error ? err.message : "Delete failed"); } finally { setAvatarLoading(false); } } async function handleUpdate(e: React.FormEvent) { e.preventDefault(); setError(""); setSuccess(""); if (newPassword && newPassword !== confirmPassword) { setError(t("auth.passwordMismatch", "Passwords do not match")); return; } if (!currentPassword || !newPassword) { setError(t("auth.fillAllFields", "Please fill in all password fields")); return; } setLoading(true); try { await updateProfile({ currentPassword: currentPassword || undefined, newPassword: newPassword || undefined, }); setSuccess(t("auth.profileUpdated", "Profile updated successfully")); setCurrentPassword(""); setNewPassword(""); setConfirmPassword(""); } catch (err) { setError(err instanceof Error ? err.message : "Update failed"); } finally { setLoading(false); } } if (!user) return null; const hasChanges = currentPassword || newPassword || confirmPassword; return (
{user.avatarUrl ? ( {user.username} ) : (
{user.username.charAt(0).toUpperCase()}
)}
{user.avatarUrl && ( )}
{user.username}

{t("auth.changePassword", "Change Password")}

{error &&
{error}
} {success &&
{success}
}
setCurrentPassword(e.target.value)} autoComplete="current-password" placeholder="••••••••" />
setNewPassword(e.target.value)} autoComplete="new-password" minLength={8} placeholder="••••••••" />
setConfirmPassword(e.target.value)} autoComplete="new-password" placeholder="••••••••" />
); } // ============================================================================= // Auth Page (combines Login/Register with routing) // ============================================================================= export function AuthPage() { const { authState } = useAuth(); const [mode, setMode] = useState<"login" | "register">("login"); // Auto-show register if no users exist yet (first setup) useEffect(() => { if (authState?.needsSetup) { setMode("register"); } }, [authState?.needsSetup]); if (mode === "register") { return setMode("login")} onSwitchToLogin={() => setMode("login")} />; } return setMode("register") : undefined} />; }