import * as SecureStore from "expo-secure-store"; import { create } from "zustand"; import { signInWithCustomToken, signOut as firebaseSignOut, } from "firebase/auth"; import { apiClient } from "@/api/client"; import type { Human } from "@/api/types"; import { firebaseAuth } from "@/firebase"; import { logError, ApiError } from "@/lib/errors"; import { startPushTokenSync, stopPushTokenSync, syncPushToken, unregisterPushToken, } from "@/lib/push-notifications"; const AUTH_TOKEN_KEY = "auth_token"; async function readPersistedToken(): Promise { try { return await SecureStore.getItemAsync(AUTH_TOKEN_KEY); } catch { // SecureStore failures are non-fatal — proceed unauthenticated. return null; } } async function persistToken(token: string): Promise { await SecureStore.setItemAsync(AUTH_TOKEN_KEY, token); } async function clearPersistedToken(): Promise { try { await SecureStore.deleteItemAsync(AUTH_TOKEN_KEY); } catch { // ignore — in-memory clear still happens via the caller } } async function signInToFirebase(): Promise { try { const { token } = await apiClient.getFirebaseToken(); await signInWithCustomToken(firebaseAuth, token); } catch (err) { // Firestore subscriptions will fail until the next successful sign-in; the // rest of the app keeps working against Orion. Sentry catches the failure. logError(err, { scope: "auth.firebase" }); } } type AuthStatus = "idle" | "restoring" | "unauthenticated" | "authenticated"; interface AuthState { status: AuthStatus; user: Human | null; token: string | null; isRequestingCode: boolean; isSigningIn: boolean; isSigningOut: boolean; error: string | null; restoreSession: () => Promise; requestCode: (email: string) => Promise; signIn: (email: string, code: string) => Promise; signOut: () => Promise; /** * Wipes the session in response to a server-detected auth failure (e.g. a * 401 surfaced through react-query). Does not call `/auth/sign-out`; the * server already considers us unauthenticated. */ invalidateSession: () => Promise; clearError: () => void; } export const useAuthStore = create((set, get) => ({ status: "idle", user: null, token: null, isRequestingCode: false, isSigningIn: false, isSigningOut: false, error: null, restoreSession: async () => { set({ status: "restoring" }); const token = await readPersistedToken(); if (!token) { set({ status: "unauthenticated" }); return; } apiClient.setToken(token); set({ token }); try { const user = await apiClient.me(); await signInToFirebase(); set({ status: "authenticated", user }); startPushTokenSync(); void syncPushToken(); } catch (err) { // Expected on expired/invalid tokens — fall back to the login screen. logError(err, { scope: "auth.restore" }); await get().invalidateSession(); } }, requestCode: async (email: string) => { set({ isRequestingCode: true, error: null }); try { await apiClient.requestCode({ email }); } catch (e) { const message = e instanceof ApiError ? e.message : "Failed to send code"; set({ error: message }); throw e; } finally { set({ isRequestingCode: false }); } }, signIn: async (email: string, code: string) => { set({ isSigningIn: true, error: null }); try { const { human, token } = await apiClient.signIn({ email, code }); await persistToken(token); apiClient.setToken(token); set({ token }); await signInToFirebase(); set({ status: "authenticated", user: human }); startPushTokenSync(); void syncPushToken(); } catch (e) { const message = e instanceof ApiError ? e.message : "Failed to sign in"; set({ error: message }); throw e; } finally { set({ isSigningIn: false }); } }, signOut: async () => { set({ isSigningOut: true }); stopPushTokenSync(); // Unregister the push token first — once the bearer token is cleared the // backend call would 401. Best-effort: failures must not block sign-out. await unregisterPushToken(); try { await apiClient.signOut(); } catch (err) { // Best-effort — sign out locally regardless. logError(err, { scope: "auth.signOut" }); } finally { await firebaseSignOut(firebaseAuth).catch((err) => logError(err, { scope: "auth.firebaseSignOut" }), ); apiClient.setToken(null); await clearPersistedToken(); set({ status: "unauthenticated", user: null, token: null, isSigningOut: false, error: null, }); } }, invalidateSession: async () => { stopPushTokenSync(); apiClient.setToken(null); await clearPersistedToken(); set({ status: "unauthenticated", user: null, token: null }); }, clearError: () => set({ error: null }), }));