import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { type Href } from "expo-router"; import { useNavigation } from "../context/NavigationContext"; import { useAppConfig } from "../context/AppConfigContext"; import AuthService, { type AuthResponse, type User } from "../services/auth.service"; /** * Outcome of a passkey login attempt. Used by apps with a client-side * unlock step (e.g. vault's symmetric-key derivation) so the login * screen can route to either the home tab or a password-prompt dialog. * * `unlocked: true` — the authenticator honored PRF and the local * unlock work is done. Route to `routes.home`. * `unlocked: false` — server accepted the assertion and the session * is established, but the consumer still needs * the master password. The login screen renders * its unlock dialog and calls `unlockWithPassword`. */ export interface PasskeyLoginResult { unlocked: boolean; user: User; } export interface LoginWithPasskeyOptions { /** * Request the "immediately-available-only, zero cross-app UI" variant * of the ceremony (native `getImmediate` / web `mediation: "silent"`): * succeeds only if a credential is available locally, with no * Credential Manager picker across other registered providers and no * modal at all on failure. Use for an auto-fired, opportunistic * attempt (e.g. a lock screen prompting for biometrics the moment it * mounts) — the normal path's full cross-app arbitration is correct * for an explicit "sign in with a passkey" tap, but was observed * colliding with another installed password manager's own autofill * prompt when fired automatically on every app launch. When true, * this call also skips the shared `isLoading`/`error` state below — * a background opportunistic check should not flash a loading state * or surface an error banner anywhere else in the app on failure. */ silent?: boolean; } interface AuthContextType { user: User | null; isLoading: boolean; isAuthenticated: boolean; login: (email: string, password: string) => Promise; /** * Drive a passkey ceremony end-to-end. Available when the consumer * provided `loginWithPasskey` to `AuthProvider`; throws otherwise. * `signal` aborts an in-flight WebAuthn ceremony on web — wire it * to a `useEffect` cleanup so unmounting the screen tears the OS * prompt down instead of leaving an orphaned resolution. */ loginWithPasskey: ( email?: string, signal?: AbortSignal, opts?: LoginWithPasskeyOptions ) => Promise; /** * Complete a non-PRF passkey unlock by deriving the local key from * the master password. Available when the consumer provided * `unlockWithPassword` to `AuthProvider`. */ unlockWithPassword: (password: string) => Promise; register: (email: string, password: string, displayName?: string) => Promise; logout: () => Promise; refreshUser: () => Promise; deleteAccount: (password: string) => Promise; checkAuth: () => Promise; error: string | null; clearError: () => void; } const AuthContext = createContext(undefined); export interface AuthProviderProps { children: React.ReactNode; /** * Optional hook called after a successful login. Vault wires its * `KeyManager.unlock(password)` here; bootstrap leaves it unset. * Throwing rolls the login back via `AuthService.logout()` and bubbles * the error to the caller. */ onAfterLogin?: (password: string, response: AuthResponse) => Promise | void; /** Optional post-register hook (mirror of `onAfterLogin`). */ onAfterRegister?: (password: string, response: AuthResponse) => Promise | void; /** * Optional pre-logout hook. Vault wires `KeyManager.lock()` and * websocket teardown here; failures are swallowed to keep logout * idempotent. */ onBeforeLogout?: () => Promise | void; /** * Optional pre-checkAuth hook. Vault uses this to fetch the server's * HIBP policy alongside the initial session probe. */ onBeforeCheckAuth?: () => Promise | void; /** * Optional passkey-ceremony driver. When provided, `useAuth()` exposes * `loginWithPasskey`; the consumer owns the WebAuthn round-trip and the * post-assertion vault unlock. Returning `{ unlocked: true }` finishes * the flow; returning `{ unlocked: false }` keeps the session but tells * the screen to collect the master password and call `unlockWithPassword`. */ loginWithPasskey?: ( email?: string, signal?: AbortSignal, opts?: LoginWithPasskeyOptions ) => Promise; /** * Optional master-password unlock. Used by the passkey flow when the * authenticator did not honor PRF. */ unlockWithPassword?: (password: string) => Promise; /** * Optional telemetry hook. Invoked with every error AuthProvider * would otherwise swallow into a `__DEV__`-gated `console.warn` — * post-login hook throw, post-register hook throw, refreshUser * failure, logout API failure, etc. Wire this to Sentry / Rollbar / * your structured-log shipper to get production visibility into * paths the UI hides from the user. * * The `context` string identifies the call site (`"login-hook"`, * `"register-hook"`, `"refresh-user"`, `"logout"`, …) so the * consumer can route or filter without parsing the error message. */ onError?: (error: unknown, context: string) => void; } export function AuthProvider({ children, onAfterLogin, onAfterRegister, onBeforeLogout, onBeforeCheckAuth, loginWithPasskey: providedLoginWithPasskey, unlockWithPassword: providedUnlockWithPassword, onError, }: AuthProviderProps) { // Stable wrapper so `console.warn`-then-onError stays a single // pattern across every silent catch in this provider. const reportError = useCallback( (error: unknown, context: string) => { if (__DEV__) { console.warn(`[auth:${context}]`, error); } try { onError?.(error, context); } catch { // Telemetry must never crash the auth flow. } }, [onError] ); const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isAuthenticated, setIsAuthenticated] = useState(false); const [error, setError] = useState(null); const router = useNavigation(); const { routes } = useAppConfig(); const authCheckRef = useRef(false); const sessionGenRef = useRef(0); const clearError = useCallback(() => { setError(null); }, []); const routeAfterAuth = useCallback( (response: AuthResponse, email: string) => { const step = response.user.onboarding_step; if (step === "email_verification") { router.replace({ pathname: routes.verifyEmail, params: { email } } as Href); } else if (step === "mfa_enrollment") { router.replace(routes.mfaEnroll); } else if (step === "import") { router.replace(routes.importData); } else if (step === "complete" || step == null) { router.replace(routes.home); } else { // Unknown step (server added a new onboarding step the // client doesn't recognize). Route home — the consumer's // route guards will catch the still-incomplete state and // either prompt an app update or re-route to welcome. // Don't strand the user in the auth flow. router.replace(routes.home); } }, [router, routes] ); const checkAuth = useCallback(async () => { if (authCheckRef.current) { return; } authCheckRef.current = true; const gen = ++sessionGenRef.current; try { setIsLoading(true); const isAuth = await AuthService.isAuthenticated(); if (gen !== sessionGenRef.current) { return; } if (isAuth) { const userData = await AuthService.getCurrentUser(); if (gen !== sessionGenRef.current) { return; } if (userData) { // Unblock navigation immediately with cached data. setUser(userData); setIsAuthenticated(true); setIsLoading(false); authCheckRef.current = false; // Refresh from the server in the background. AuthService.refreshUserData() .then((fresh) => { if (fresh && gen === sessionGenRef.current) { setUser(fresh); } }) .catch((e) => reportError(e, "background-refresh")); return; } else { await AuthService.logout(); if (gen !== sessionGenRef.current) { return; } setUser(null); setIsAuthenticated(false); } } else { setUser(null); setIsAuthenticated(false); } } catch (e) { // Distinguish a transport failure (server unreachable on cold // start) from a real auth failure. Falling through to // "logged out" on transient network errors paints the welcome // screen unjustly to a user whose cookie is still valid, then // flips back the moment a request lands. Keep any persisted // user record while we wait for the next probe. if (gen !== sessionGenRef.current) { return; } const persisted = await AuthService.getCurrentUser().catch(() => null); if (gen !== sessionGenRef.current) { return; } if (persisted) { setUser(persisted); setIsAuthenticated(true); reportError(e instanceof Error ? e : new Error(String(e)), "check-auth-transient"); } else { setUser(null); setIsAuthenticated(false); } } finally { if (gen === sessionGenRef.current) { setIsLoading(false); } authCheckRef.current = false; } }, [reportError]); useEffect(() => { AuthService.loadServerUrlOverride() .then(() => (onBeforeCheckAuth ? onBeforeCheckAuth() : undefined)) .catch((e) => reportError(e, "pre-check-auth")) .then(() => checkAuth()); }, [checkAuth, onBeforeCheckAuth, reportError]); const login = useCallback( async (email: string, password: string) => { try { setIsLoading(true); setError(null); const response = await AuthService.login(email, password); if (onAfterLogin) { try { await onAfterLogin(password, response); } catch (hookErr) { await AuthService.logout(); const message = hookErr instanceof Error ? hookErr.message : "Post-login hook failed"; // Deliberately do NOT attach `cause: hookErr` — the inner // error from the consumer's post-login hook may carry // key-derivation context (raw masterPassword references, // server payloads) that would be exposed if downstream // logging surfaces `err.cause` (Sentry, devtools, …). throw new Error(message); } } setUser(response.user); setIsAuthenticated(true); routeAfterAuth(response, email); } catch (err) { const message = err instanceof Error ? err.message : "Login failed"; setError(message); throw err; } finally { setIsLoading(false); } }, [onAfterLogin, routeAfterAuth] ); const register = useCallback( async (email: string, password: string, displayName?: string) => { try { setIsLoading(true); setError(null); const response = await AuthService.register(email, password, displayName); if (onAfterRegister) { try { await onAfterRegister(password, response); } catch (hookErr) { await AuthService.logout(); const message = hookErr instanceof Error ? hookErr.message : "Post-register hook failed"; // No `cause` chain — see login() above for rationale. throw new Error(message); } } setUser(response.user); setIsAuthenticated(true); routeAfterAuth(response, email); } catch (err) { const message = err instanceof Error ? err.message : "Registration failed"; setError(message); throw err; } finally { setIsLoading(false); } }, [onAfterRegister, routeAfterAuth] ); const logout = useCallback(async () => { sessionGenRef.current++; setIsLoading(true); // Flip React auth state FIRST so any in-flight queries that // re-render against `user` / `isAuthenticated` see "logged out" // immediately. Previously this happened after `AuthService.logout()` // resolved, opening a window where the pre-logout hook had already // torn down KeyManager / queryClient / WS but components still // rendered as authenticated — any React-Query refetch in that // window would hit a half-disassembled client. setUser(null); setIsAuthenticated(false); if (onBeforeLogout) { try { await onBeforeLogout(); } catch (e) { // pre-logout hook errors are non-fatal — proceed to clear // state. Telemetry sees it via the onError surface. reportError(e, "pre-logout-hook"); } } router.replace(routes.login); // Server-side revoke runs in the background. Failure is also // surfaced through the onError hook so consumers can detect // half-broken logouts (server session still alive after the // user thinks they're gone). try { await AuthService.logout(); } catch (e) { reportError(e, "logout-revoke"); } setIsLoading(false); }, [onBeforeLogout, router, routes, reportError]); const refreshUser = useCallback(async () => { try { const userData = await AuthService.refreshUserData(); if (userData) { setUser(userData); setIsAuthenticated(true); } else { setUser(null); setIsAuthenticated(false); router.replace(routes.login); } } catch { // Refresh failed — try again on next interval. } }, [router, routes]); const deleteAccount = useCallback( async (password: string) => { sessionGenRef.current++; try { setIsLoading(true); await AuthService.deleteAccount(password); if (onBeforeLogout) { try { await onBeforeLogout(); } catch { // ignore } } setUser(null); setIsAuthenticated(false); router.replace(routes.welcome); } catch (err) { const message = err instanceof Error ? err.message : "Failed to delete account"; setError(message); throw err; } finally { setIsLoading(false); } }, [onBeforeLogout, router, routes] ); const loginWithPasskey = useCallback( async ( email?: string, signal?: AbortSignal, opts?: LoginWithPasskeyOptions ): Promise => { if (!providedLoginWithPasskey) { throw new Error( "entropy-ui: loginWithPasskey() called but AuthProvider wasn't given a loginWithPasskey handler." ); } const silent = opts?.silent ?? false; try { if (!silent) { setIsLoading(true); setError(null); } const result = await providedLoginWithPasskey(email, signal, opts); setUser(result.user); setIsAuthenticated(true); return result; } catch (err) { if (!silent) { const message = err instanceof Error ? err.message : "Passkey sign-in failed"; setError(message); } throw err; } finally { if (!silent) { setIsLoading(false); } } }, [providedLoginWithPasskey] ); const unlockWithPassword = useCallback( async (password: string): Promise => { if (!providedUnlockWithPassword) { throw new Error( "entropy-ui: unlockWithPassword() called but AuthProvider wasn't given an unlockWithPassword handler." ); } await providedUnlockWithPassword(password); }, [providedUnlockWithPassword] ); const value = useMemo( () => ({ user, isLoading, isAuthenticated, login, loginWithPasskey, unlockWithPassword, register, logout, refreshUser, deleteAccount, checkAuth, error, clearError, }), [ user, isLoading, isAuthenticated, login, loginWithPasskey, unlockWithPassword, register, logout, refreshUser, deleteAccount, checkAuth, error, clearError, ] ); return {children}; } export function useAuth(): AuthContextType { const context = useContext(AuthContext); if (context === undefined) { throw new Error("useAuth must be used within an AuthProvider"); } return context; } export type { AuthContextType }; export function useRole(requiredRole: "Admin" | "User"): boolean { const { user } = useAuth(); if (!user) { return false; } if (requiredRole === "User") { return true; } return user.role === "Admin"; } export function useRequireAuth(redirectTo?: Href) { const { isAuthenticated, isLoading } = useAuth(); const router = useNavigation(); const { routes } = useAppConfig(); const target = redirectTo ?? routes.login; useEffect(() => { if (!isLoading && !isAuthenticated) { router.replace(target); } }, [isAuthenticated, isLoading, router, target]); return { isAuthenticated, isLoading }; } export function useSession() { const { user, refreshUser, isAuthenticated } = useAuth(); const intervalRef = useRef | undefined>(undefined); const refreshingRef = useRef(false); useEffect(() => { if (isAuthenticated) { intervalRef.current = setInterval( () => { if (refreshingRef.current) { return; } refreshingRef.current = true; refreshUser() .catch((e) => { // `useSession` lives outside the provider's // reportError closure; auto-refresh failures only // surface in dev. The `refreshUser()` impl itself // reports via onError when wired. if (__DEV__) { console.warn("[auth:session-refresh-interval]", e); } }) .finally(() => { refreshingRef.current = false; }); }, 10 * 60 * 1000 ); } return () => { if (intervalRef.current) { clearInterval(intervalRef.current); } }; }, [isAuthenticated, refreshUser]); return { user }; } // Validators live in `utils/validation` and are re-exported via the // utils barrel. Existing consumers that imported them from this module // pull them from the package root barrel instead. export { validateEmail, validatePassword, validateUsername } from "../utils/validation"; export default useAuth;