import * as SecureStore from "expo-secure-store"; import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode, } from "react"; import { setAuthToken, clearAuthToken } from "./fetch"; const TOKEN_KEY = "waseel_auth_token"; const USER_KEY = "waseel_auth_user"; const REMEMBERED_EMAIL_KEY = "waseel_remembered_email"; const DEFAULT_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; /** * The address to prefill on the sign-in screen, or null when the last sign-in * cleared "remember me". The token itself is stored either way — what * "remember me" changes is how long the server makes it live. */ export const getRememberedEmail = (): Promise => SecureStore.getItemAsync(REMEMBERED_EMAIL_KEY); export const rememberEmail = async (email: string | null): Promise => { if (email) { await SecureStore.setItemAsync(REMEMBERED_EMAIL_KEY, email); } else { await SecureStore.deleteItemAsync(REMEMBERED_EMAIL_KEY); } }; export type SessionUser = { id: string; name: string; email: string; role?: string | null; avatarUrl?: string | null; }; type AuthResult = { token: string; user: SessionUser }; type SessionContextValue = { isLoaded: boolean; isSignedIn: boolean; userId: string | null; user: SessionUser | null; setSession: (result: AuthResult) => Promise; setUserRole: (role: string) => void; signOut: () => Promise; }; const SessionContext = createContext(null); const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; /** * Decodes a base64url segment without `atob`. * * Neither React Native 0.74 nor Expo SDK 51 defines a global `atob`, so the * previous implementation threw on every call — which silently expired the * stored session on each launch (see decodeJwtExp). Skipping unknown * characters also makes the missing "=" padding in a JWT a non-issue. */ const decodeBase64Url = (segment: string): string => { const normalized = segment.replace(/-/g, "+").replace(/_/g, "/"); let output = ""; let buffer = 0; let bits = 0; for (const character of normalized) { const value = BASE64_ALPHABET.indexOf(character); if (value === -1) continue; buffer = (buffer << 6) | value; bits += 6; if (bits >= 8) { bits -= 8; output += String.fromCharCode((buffer >> bits) & 0xff); } } return output; }; // Mirrors lib/jwt.ts payload decoding (no signature check needed client-side). export const decodeJwtExp = (token: string): number | null => { try { const claims = JSON.parse(decodeBase64Url(token.split(".")[1])); return typeof claims.exp === "number" ? claims.exp : null; } catch { return null; } }; export const SessionProvider = ({ children }: { children: ReactNode }) => { const [isLoaded, setIsLoaded] = useState(false); const [user, setUser] = useState(null); useEffect(() => { let cancelled = false; const restore = async () => { try { const [token, storedUser] = await Promise.all([ SecureStore.getItemAsync(TOKEN_KEY), SecureStore.getItemAsync(USER_KEY), ]); if (!token || !storedUser) return; const exp = decodeJwtExp(token); // Only discard a session we can positively prove is expired. Treating // an unreadable token as expired is what made every launch sign the // user back out; if it really is bad, the next request gets a 401. if (exp === null) { console.warn("[SESSION_RESTORE]: could not read token expiry"); } else if (exp * 1000 < Date.now()) { await SecureStore.deleteItemAsync(TOKEN_KEY); await SecureStore.deleteItemAsync(USER_KEY); return; } if (cancelled) return; setAuthToken(token); setUser(JSON.parse(storedUser) as SessionUser); } catch (error) { console.error("[SESSION_RESTORE]: ", error); } finally { if (!cancelled) setIsLoaded(true); } }; void restore(); return () => { cancelled = true; }; }, []); const setSession = useCallback(async (result: AuthResult) => { setAuthToken(result.token); setUser(result.user); await SecureStore.setItemAsync(TOKEN_KEY, result.token); await SecureStore.setItemAsync(USER_KEY, JSON.stringify(result.user)); }, []); const setUserRole = useCallback( (role: string) => { setUser((currentUser) => { if (!currentUser) return currentUser; const updated = { ...currentUser, role }; void SecureStore.setItemAsync(USER_KEY, JSON.stringify(updated)); return updated; }); }, [], ); const signOut = useCallback(async () => { clearAuthToken(); setUser(null); await SecureStore.deleteItemAsync(TOKEN_KEY); await SecureStore.deleteItemAsync(USER_KEY); }, []); const value = useMemo( () => ({ isLoaded, isSignedIn: user !== null, userId: user?.id ?? null, user, setSession, setUserRole, signOut, }), [isLoaded, user, setSession, setUserRole, signOut], ); return ( {children} ); }; export const useSession = (): SessionContextValue => { const context = useContext(SessionContext); if (!context) { throw new Error("useSession must be used within a SessionProvider."); } return context; }; // Alias kept for parity with the previous auth API. export const useAuth = useSession; export const TOKEN_TTL_SECONDS = DEFAULT_TOKEN_TTL_SECONDS;