@@ -1,3 +1,4 @@
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
signInWithCustomToken,
|
||||
@@ -8,10 +9,34 @@ 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";
|
||||
import { hydrateSession, useSessionStore } from "./session-store";
|
||||
|
||||
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 {
|
||||
@@ -29,6 +54,7 @@ type AuthStatus = "idle" | "restoring" | "unauthenticated" | "authenticated";
|
||||
interface AuthState {
|
||||
status: AuthStatus;
|
||||
user: Human | null;
|
||||
token: string | null;
|
||||
isRequestingCode: boolean;
|
||||
isSigningIn: boolean;
|
||||
isSigningOut: boolean;
|
||||
@@ -37,12 +63,19 @@ interface AuthState {
|
||||
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) => ({
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
status: "idle",
|
||||
user: null,
|
||||
token: null,
|
||||
isRequestingCode: false,
|
||||
isSigningIn: false,
|
||||
isSigningOut: false,
|
||||
@@ -50,26 +83,26 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
|
||||
restoreSession: async () => {
|
||||
set({ status: "restoring" });
|
||||
if (!useSessionStore.getState().hydrated) {
|
||||
await hydrateSession();
|
||||
}
|
||||
|
||||
const token = useSessionStore.getState().token;
|
||||
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 useSessionStore.getState().clearToken();
|
||||
set({ status: "unauthenticated", user: null });
|
||||
await get().invalidateSession();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -91,9 +124,12 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
set({ isSigningIn: true, error: null });
|
||||
try {
|
||||
const { human, token } = await apiClient.signIn({ email, code });
|
||||
await useSessionStore.getState().setToken(token);
|
||||
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";
|
||||
@@ -106,7 +142,8 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
|
||||
signOut: async () => {
|
||||
set({ isSigningOut: true });
|
||||
// Unregister the push token first — once the session token is cleared the
|
||||
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 {
|
||||
@@ -118,29 +155,24 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
await firebaseSignOut(firebaseAuth).catch((err) =>
|
||||
logError(err, { scope: "auth.firebaseSignOut" }),
|
||||
);
|
||||
await useSessionStore.getState().clearToken();
|
||||
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 }),
|
||||
}));
|
||||
|
||||
// React to token being cleared externally (e.g. 401 from API client).
|
||||
useSessionStore.subscribe((state, prevState) => {
|
||||
if (prevState.token && !state.token) {
|
||||
const authState = useAuthStore.getState();
|
||||
if (authState.status === "authenticated") {
|
||||
useAuthStore.setState({
|
||||
status: "unauthenticated",
|
||||
user: null,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { create } from "zustand";
|
||||
|
||||
const AUTH_TOKEN_KEY = "auth_token";
|
||||
|
||||
interface SessionState {
|
||||
token: string | null;
|
||||
/**
|
||||
* False until SecureStore returns the persisted token (or confirms absence).
|
||||
* The API client should treat requests as unauthenticated until this flips —
|
||||
* see `useSessionStore.subscribe` in App.tsx for the bootstrap.
|
||||
*/
|
||||
hydrated: boolean;
|
||||
setToken: (token: string) => Promise<void>;
|
||||
clearToken: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSessionStore = create<SessionState>((set) => ({
|
||||
token: null,
|
||||
hydrated: false,
|
||||
|
||||
setToken: async (token: string) => {
|
||||
await SecureStore.setItemAsync(AUTH_TOKEN_KEY, token);
|
||||
set({ token });
|
||||
},
|
||||
|
||||
clearToken: async () => {
|
||||
await SecureStore.deleteItemAsync(AUTH_TOKEN_KEY);
|
||||
set({ token: null });
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Bootstrap the session by reading SecureStore once. Call from App.tsx before
|
||||
* mounting the navigator. Resolves after the store reflects whatever was in
|
||||
* persistent storage.
|
||||
*/
|
||||
export async function hydrateSession(): Promise<void> {
|
||||
try {
|
||||
const token = await SecureStore.getItemAsync(AUTH_TOKEN_KEY);
|
||||
useSessionStore.setState({ token: token ?? null, hydrated: true });
|
||||
} catch {
|
||||
// SecureStore failures are non-fatal — proceed unauthenticated.
|
||||
useSessionStore.setState({ token: null, hydrated: true });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user