181 lines
5.0 KiB
TypeScript
181 lines
5.0 KiB
TypeScript
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<string | null> {
|
|
try {
|
|
return await SecureStore.getItemAsync(AUTH_TOKEN_KEY);
|
|
} catch {
|
|
// SecureStore failures are non-fatal — proceed unauthenticated.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function persistToken(token: string): Promise<void> {
|
|
await SecureStore.setItemAsync(AUTH_TOKEN_KEY, token);
|
|
}
|
|
|
|
async function clearPersistedToken(): Promise<void> {
|
|
try {
|
|
await SecureStore.deleteItemAsync(AUTH_TOKEN_KEY);
|
|
} catch {
|
|
// ignore — in-memory clear still happens via the caller
|
|
}
|
|
}
|
|
|
|
async function signInToFirebase(): Promise<void> {
|
|
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<void>;
|
|
requestCode: (email: string) => Promise<void>;
|
|
signIn: (email: string, code: string) => Promise<void>;
|
|
signOut: () => Promise<void>;
|
|
/**
|
|
* 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<void>;
|
|
clearError: () => void;
|
|
}
|
|
|
|
export const useAuthStore = create<AuthState>((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 });
|
|
|
|
await unregisterPushToken(); // Best-effort
|
|
},
|
|
|
|
clearError: () => set({ error: null }),
|
|
}));
|