@@ -20,7 +20,9 @@ import { useAuthStore } from "@/stores/auth-store";
|
|||||||
|
|
||||||
const queryClient = createQueryClient();
|
const queryClient = createQueryClient();
|
||||||
|
|
||||||
// One-time setup: foreground handler + push-token rotation listener. Idempotent.
|
// One-time setup: foreground handler + tap routing. Idempotent. Note that
|
||||||
|
// push-token rotation sync is NOT set up here — that's owned by the auth
|
||||||
|
// store and only runs while a session is active.
|
||||||
configureNotifications();
|
configureNotifications();
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|||||||
+16
-24
@@ -1,5 +1,4 @@
|
|||||||
import { appConfig } from "@/config/env";
|
import { appConfig } from "@/config/env";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
|
||||||
import { ApiError } from "@/lib/errors";
|
import { ApiError } from "@/lib/errors";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import {
|
import {
|
||||||
@@ -28,17 +27,20 @@ import type {
|
|||||||
SignInRequest,
|
SignInRequest,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
interface ApiClientConfig {
|
/**
|
||||||
baseUrl: string;
|
* HTTP transport for Orion. Holds the bearer token as private state — the auth
|
||||||
getToken: () => string | null;
|
* store pushes it in via {@link setToken} on sign-in / restore and clears it
|
||||||
onUnauthorized: () => void;
|
* on sign-out. The client itself has no opinion about what a 401 means; it
|
||||||
}
|
* just throws, and the query-client onError handler is the single place that
|
||||||
|
* turns a 401 into a session invalidation.
|
||||||
|
*/
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
private config: ApiClientConfig;
|
private token: string | null = null;
|
||||||
|
|
||||||
constructor(config: ApiClientConfig) {
|
constructor(private readonly baseUrl: string) {}
|
||||||
this.config = config;
|
|
||||||
|
setToken(token: string | null): void {
|
||||||
|
this.token = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetch(
|
private async fetch(
|
||||||
@@ -52,19 +54,17 @@ class ApiClient {
|
|||||||
headers["Content-Type"] = "application/json";
|
headers["Content-Type"] = "application/json";
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = this.config.getToken();
|
if (this.token) {
|
||||||
if (token) {
|
headers["Authorization"] = `Bearer ${this.token}`;
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
this.config.onUnauthorized();
|
|
||||||
throw new ApiError(401, "Unauthorized");
|
throw new ApiError(401, "Unauthorized");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,12 +273,4 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = new ApiClient({
|
export const apiClient = new ApiClient(appConfig.orionUrl);
|
||||||
baseUrl: appConfig.orionUrl,
|
|
||||||
getToken: () => useSessionStore.getState().token,
|
|
||||||
// SecureStore writes are async; we fire-and-forget so the throwing
|
|
||||||
// request doesn't have to wait for persistence to finish.
|
|
||||||
onUnauthorized: () => {
|
|
||||||
void useSessionStore.getState().clearToken();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ let configured = false;
|
|||||||
let tokenListenerSubscription: Notifications.Subscription | null = null;
|
let tokenListenerSubscription: Notifications.Subscription | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the foreground notification handler so banners show while the app is
|
* Sets the foreground notification handler and tap routing. Safe to call
|
||||||
* open, and subscribes to Expo's token-rotation listener so the backend stays
|
* multiple times. Does NOT subscribe to Expo's token-rotation listener —
|
||||||
* in sync without the user needing to re-launch. Safe to call multiple times.
|
* that's the auth store's job via {@link startPushTokenSync}, so token sync
|
||||||
|
* only runs while a session is active. Subscribing here at module scope used
|
||||||
|
* to fire `apiClient.registerPushToken` before SecureStore hydration completed
|
||||||
|
* and silently invalidated the user's real session.
|
||||||
*/
|
*/
|
||||||
export function configureNotifications(): void {
|
export function configureNotifications(): void {
|
||||||
if (configured) return;
|
if (configured) return;
|
||||||
@@ -30,12 +33,6 @@ export function configureNotifications(): void {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
tokenListenerSubscription = Notifications.addPushTokenListener((event) => {
|
|
||||||
// Token rotated server-side by Expo or APNs. Sync immediately so we don't
|
|
||||||
// keep pushing to a dead token.
|
|
||||||
void syncPushToken(event.data);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Warm-state taps (app in background or foreground). Cold-start taps are
|
// Warm-state taps (app in background or foreground). Cold-start taps are
|
||||||
// drained separately via getLastNotificationResponseAsync; see
|
// drained separately via getLastNotificationResponseAsync; see
|
||||||
// flushPendingNavigation in notification-routing.ts.
|
// flushPendingNavigation in notification-routing.ts.
|
||||||
@@ -48,6 +45,27 @@ export function configureNotifications(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to Expo's token-rotation listener. Called by the auth store
|
||||||
|
* after a successful sign-in or session restore so rotation events only fire
|
||||||
|
* `syncPushToken` while authenticated. Idempotent.
|
||||||
|
*/
|
||||||
|
export function startPushTokenSync(): void {
|
||||||
|
if (tokenListenerSubscription) return;
|
||||||
|
tokenListenerSubscription = Notifications.addPushTokenListener((event) => {
|
||||||
|
void syncPushToken(event.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tears down the rotation listener. Called by the auth store on sign-out and
|
||||||
|
* session invalidation. Idempotent.
|
||||||
|
*/
|
||||||
|
export function stopPushTokenSync(): void {
|
||||||
|
tokenListenerSubscription?.remove();
|
||||||
|
tokenListenerSubscription = null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Acquires (or returns the cached) Expo push token for this device. Returns
|
* Acquires (or returns the cached) Expo push token for this device. Returns
|
||||||
* null on simulators, when permission is denied, or when any step fails — the
|
* null on simulators, when permission is denied, or when any step fails — the
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { PusherClient, type ConnectionState } from "./pusher-client";
|
import { PusherClient, type ConnectionState } from "./pusher-client";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { appConfig } from "@/config/env";
|
import { appConfig } from "@/config/env";
|
||||||
|
|
||||||
const PusherContext = createContext<PusherClient | null>(null);
|
const PusherContext = createContext<PusherClient | null>(null);
|
||||||
const PusherStateContext = createContext<ConnectionState>("disconnected");
|
const PusherStateContext = createContext<ConnectionState>("disconnected");
|
||||||
|
|
||||||
export function PusherProvider({ children }: { children: ReactNode }) {
|
export function PusherProvider({ children }: { children: ReactNode }) {
|
||||||
const token = useSessionStore((s) => s.token);
|
const token = useAuthStore((s) => s.token);
|
||||||
const clientRef = useRef<PusherClient | null>(null);
|
const clientRef = useRef<PusherClient | null>(null);
|
||||||
const [connectionState, setConnectionState] =
|
const [connectionState, setConnectionState] =
|
||||||
useState<ConnectionState>("disconnected");
|
useState<ConnectionState>("disconnected");
|
||||||
@@ -31,7 +31,7 @@ export function PusherProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const client = new PusherClient({
|
const client = new PusherClient({
|
||||||
url: appConfig.pusherUrl,
|
url: appConfig.pusherUrl,
|
||||||
getToken: () => useSessionStore.getState().token,
|
getToken: () => useAuthStore.getState().token,
|
||||||
});
|
});
|
||||||
|
|
||||||
clientRef.current = client;
|
clientRef.current = client;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { ApiError, logError, reportError, toUserMessage } from "@/lib/errors";
|
import { ApiError, logError, reportError, toUserMessage } from "@/lib/errors";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
|
||||||
declare module "@tanstack/react-query" {
|
declare module "@tanstack/react-query" {
|
||||||
interface Register {
|
interface Register {
|
||||||
@@ -22,6 +23,16 @@ function shouldRetryQuery(failureCount: number, err: unknown): boolean {
|
|||||||
return failureCount < 2;
|
return failureCount < 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A 401 surfaced through react-query means the server rejected our bearer
|
||||||
|
// token. This is the *only* place that turns that into an auth state change —
|
||||||
|
// the apiClient is a dumb transport. Direct apiClient callers (signIn,
|
||||||
|
// restoreSession, signInToFirebase) handle their own 401s explicitly.
|
||||||
|
function handleUnauthorized(err: unknown): void {
|
||||||
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
void useAuthStore.getState().invalidateSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createQueryClient(): QueryClient {
|
export function createQueryClient(): QueryClient {
|
||||||
return new QueryClient({
|
return new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -36,6 +47,7 @@ export function createQueryClient(): QueryClient {
|
|||||||
},
|
},
|
||||||
queryCache: new QueryCache({
|
queryCache: new QueryCache({
|
||||||
onError: (err, query) => {
|
onError: (err, query) => {
|
||||||
|
handleUnauthorized(err);
|
||||||
logError(err, { scope: "query", queryKey: query.queryKey });
|
logError(err, { scope: "query", queryKey: query.queryKey });
|
||||||
if (query.meta?.toastOnError) {
|
if (query.meta?.toastOnError) {
|
||||||
toast.error(toUserMessage(err));
|
toast.error(toUserMessage(err));
|
||||||
@@ -44,6 +56,7 @@ export function createQueryClient(): QueryClient {
|
|||||||
}),
|
}),
|
||||||
mutationCache: new MutationCache({
|
mutationCache: new MutationCache({
|
||||||
onError: (err, _variables, _context, mutation) => {
|
onError: (err, _variables, _context, mutation) => {
|
||||||
|
handleUnauthorized(err);
|
||||||
reportError(err, {
|
reportError(err, {
|
||||||
scope: "mutation",
|
scope: "mutation",
|
||||||
mutationKey: mutation.options.mutationKey,
|
mutationKey: mutation.options.mutationKey,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as SecureStore from "expo-secure-store";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import {
|
import {
|
||||||
signInWithCustomToken,
|
signInWithCustomToken,
|
||||||
@@ -8,10 +9,34 @@ import type { Human } from "@/api/types";
|
|||||||
import { firebaseAuth } from "@/firebase";
|
import { firebaseAuth } from "@/firebase";
|
||||||
import { logError, ApiError } from "@/lib/errors";
|
import { logError, ApiError } from "@/lib/errors";
|
||||||
import {
|
import {
|
||||||
|
startPushTokenSync,
|
||||||
|
stopPushTokenSync,
|
||||||
syncPushToken,
|
syncPushToken,
|
||||||
unregisterPushToken,
|
unregisterPushToken,
|
||||||
} from "@/lib/push-notifications";
|
} 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> {
|
async function signInToFirebase(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -29,6 +54,7 @@ type AuthStatus = "idle" | "restoring" | "unauthenticated" | "authenticated";
|
|||||||
interface AuthState {
|
interface AuthState {
|
||||||
status: AuthStatus;
|
status: AuthStatus;
|
||||||
user: Human | null;
|
user: Human | null;
|
||||||
|
token: string | null;
|
||||||
isRequestingCode: boolean;
|
isRequestingCode: boolean;
|
||||||
isSigningIn: boolean;
|
isSigningIn: boolean;
|
||||||
isSigningOut: boolean;
|
isSigningOut: boolean;
|
||||||
@@ -37,12 +63,19 @@ interface AuthState {
|
|||||||
requestCode: (email: string) => Promise<void>;
|
requestCode: (email: string) => Promise<void>;
|
||||||
signIn: (email: string, code: string) => Promise<void>;
|
signIn: (email: string, code: string) => Promise<void>;
|
||||||
signOut: () => 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;
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set) => ({
|
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||||
status: "idle",
|
status: "idle",
|
||||||
user: null,
|
user: null,
|
||||||
|
token: null,
|
||||||
isRequestingCode: false,
|
isRequestingCode: false,
|
||||||
isSigningIn: false,
|
isSigningIn: false,
|
||||||
isSigningOut: false,
|
isSigningOut: false,
|
||||||
@@ -50,26 +83,26 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
|
|
||||||
restoreSession: async () => {
|
restoreSession: async () => {
|
||||||
set({ status: "restoring" });
|
set({ status: "restoring" });
|
||||||
if (!useSessionStore.getState().hydrated) {
|
|
||||||
await hydrateSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = useSessionStore.getState().token;
|
const token = await readPersistedToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
set({ status: "unauthenticated" });
|
set({ status: "unauthenticated" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apiClient.setToken(token);
|
||||||
|
set({ token });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await apiClient.me();
|
const user = await apiClient.me();
|
||||||
await signInToFirebase();
|
await signInToFirebase();
|
||||||
set({ status: "authenticated", user });
|
set({ status: "authenticated", user });
|
||||||
|
startPushTokenSync();
|
||||||
void syncPushToken();
|
void syncPushToken();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Expected on expired/invalid tokens — fall back to the login screen.
|
// Expected on expired/invalid tokens — fall back to the login screen.
|
||||||
logError(err, { scope: "auth.restore" });
|
logError(err, { scope: "auth.restore" });
|
||||||
await useSessionStore.getState().clearToken();
|
await get().invalidateSession();
|
||||||
set({ status: "unauthenticated", user: null });
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -91,9 +124,12 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
set({ isSigningIn: true, error: null });
|
set({ isSigningIn: true, error: null });
|
||||||
try {
|
try {
|
||||||
const { human, token } = await apiClient.signIn({ email, code });
|
const { human, token } = await apiClient.signIn({ email, code });
|
||||||
await useSessionStore.getState().setToken(token);
|
await persistToken(token);
|
||||||
|
apiClient.setToken(token);
|
||||||
|
set({ token });
|
||||||
await signInToFirebase();
|
await signInToFirebase();
|
||||||
set({ status: "authenticated", user: human });
|
set({ status: "authenticated", user: human });
|
||||||
|
startPushTokenSync();
|
||||||
void syncPushToken();
|
void syncPushToken();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = e instanceof ApiError ? e.message : "Failed to sign in";
|
const message = e instanceof ApiError ? e.message : "Failed to sign in";
|
||||||
@@ -106,7 +142,8 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
|
|
||||||
signOut: async () => {
|
signOut: async () => {
|
||||||
set({ isSigningOut: true });
|
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.
|
// backend call would 401. Best-effort: failures must not block sign-out.
|
||||||
await unregisterPushToken();
|
await unregisterPushToken();
|
||||||
try {
|
try {
|
||||||
@@ -118,29 +155,24 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
await firebaseSignOut(firebaseAuth).catch((err) =>
|
await firebaseSignOut(firebaseAuth).catch((err) =>
|
||||||
logError(err, { scope: "auth.firebaseSignOut" }),
|
logError(err, { scope: "auth.firebaseSignOut" }),
|
||||||
);
|
);
|
||||||
await useSessionStore.getState().clearToken();
|
apiClient.setToken(null);
|
||||||
|
await clearPersistedToken();
|
||||||
set({
|
set({
|
||||||
status: "unauthenticated",
|
status: "unauthenticated",
|
||||||
user: null,
|
user: null,
|
||||||
|
token: null,
|
||||||
isSigningOut: false,
|
isSigningOut: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
invalidateSession: async () => {
|
||||||
|
stopPushTokenSync();
|
||||||
|
apiClient.setToken(null);
|
||||||
|
await clearPersistedToken();
|
||||||
|
set({ status: "unauthenticated", user: null, token: null });
|
||||||
|
},
|
||||||
|
|
||||||
clearError: () => set({ error: 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