Stabilize mobile auth session handling
This commit is contained in:
+13
-4
@@ -1,24 +1,33 @@
|
||||
import { router } from "expo-router";
|
||||
import { Alert } from "react-native";
|
||||
import type { createAuthClient } from "better-auth/react";
|
||||
|
||||
import type { RemoveAccountResult } from "@/contexts/AccountsContext";
|
||||
import { performAuthReset } from "@/lib/auth-session";
|
||||
|
||||
type AuthClient = ReturnType<typeof createAuthClient>;
|
||||
|
||||
type FinishAccountRemovalInput = {
|
||||
result: RemoveAccountResult;
|
||||
authClient: AuthClient;
|
||||
clearActiveAccount: () => Promise<void>;
|
||||
signOut: () => Promise<unknown>;
|
||||
activeAccountId: string | null;
|
||||
};
|
||||
|
||||
/** Navigate to sign-in when the last saved account was removed. */
|
||||
export async function finishAccountRemoval({
|
||||
result,
|
||||
authClient,
|
||||
clearActiveAccount,
|
||||
signOut,
|
||||
activeAccountId,
|
||||
}: FinishAccountRemovalInput): Promise<void> {
|
||||
if (result.remainingCount > 0) return;
|
||||
|
||||
await signOut();
|
||||
await clearActiveAccount();
|
||||
await performAuthReset({
|
||||
authClient,
|
||||
clearActiveAccount,
|
||||
activeAccountId,
|
||||
});
|
||||
router.replace("/(auth)/sign-in");
|
||||
}
|
||||
|
||||
|
||||
+4
-10
@@ -1,11 +1,5 @@
|
||||
import { getApiUrl } from "@/lib/config";
|
||||
|
||||
type ApiError = { error?: string; message?: string };
|
||||
|
||||
async function parseError(res: Response) {
|
||||
const data = (await res.json().catch(() => ({}))) as ApiError;
|
||||
return data.error ?? data.message ?? "Something went wrong";
|
||||
}
|
||||
import { readHttpErrorMessage } from "@/lib/trpc-errors";
|
||||
|
||||
export async function registerAccount(input: {
|
||||
firstName: string;
|
||||
@@ -20,7 +14,7 @@ export async function registerAccount(input: {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseError(res));
|
||||
throw new Error(await readHttpErrorMessage(res));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +26,7 @@ export async function requestPasswordReset(email: string) {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseError(res));
|
||||
throw new Error(await readHttpErrorMessage(res));
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { message?: string };
|
||||
@@ -47,6 +41,6 @@ export async function resetPassword(token: string, password: string) {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseError(res));
|
||||
throw new Error(await readHttpErrorMessage(res));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { getCookie as serializeStoredCookies } from "@better-auth/expo/client";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import type { createAuthClient } from "better-auth/react";
|
||||
|
||||
import { normalizeSecureStoreKey } from "@/lib/secure-store-keys";
|
||||
|
||||
type AuthClient = ReturnType<typeof createAuthClient>;
|
||||
|
||||
const CHUNK_MARKER = "\u0001ba-chunks:";
|
||||
|
||||
function readSecureStoreValueSync(key: string): string | null {
|
||||
const value = SecureStore.getItem(key);
|
||||
if (value == null) return null;
|
||||
if (!value.startsWith(CHUNK_MARKER)) return value;
|
||||
|
||||
const count = Number(value.slice(CHUNK_MARKER.length));
|
||||
if (!Number.isInteger(count) || count < 1) return null;
|
||||
|
||||
let assembled = "";
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const chunk = SecureStore.getItem(`${key}.${index}`);
|
||||
if (chunk == null) return null;
|
||||
assembled += chunk;
|
||||
}
|
||||
return assembled;
|
||||
}
|
||||
|
||||
/** Read session cookie string for tRPC requests (Expo client plugin + SecureStore fallback). */
|
||||
export function getAuthCookie(
|
||||
authClient: AuthClient,
|
||||
storagePrefix: string,
|
||||
): string | null {
|
||||
const fromClient = (
|
||||
authClient as AuthClient & { getCookie?: () => string }
|
||||
).getCookie?.();
|
||||
if (fromClient?.trim()) return fromClient.trim();
|
||||
|
||||
const raw = readSecureStoreValueSync(
|
||||
normalizeSecureStoreKey(`${storagePrefix}_cookie`),
|
||||
);
|
||||
if (!raw || raw === "{}") return null;
|
||||
|
||||
const cookie = serializeStoredCookies(raw);
|
||||
return cookie.trim() || null;
|
||||
}
|
||||
|
||||
export function getAuthCookieHeaders(
|
||||
authClient: AuthClient,
|
||||
storagePrefix: string,
|
||||
): Record<string, string> {
|
||||
const cookie = getAuthCookie(authClient, storagePrefix);
|
||||
return cookie
|
||||
? { cookie, Cookie: cookie, "x-beenvoice-auth-cookie": cookie }
|
||||
: {};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { createAuthClient } from "better-auth/react";
|
||||
|
||||
import { authStoragePrefix } from "@/lib/accounts";
|
||||
import {
|
||||
clearAuthStorage,
|
||||
GUEST_AUTH_STORAGE_PREFIX,
|
||||
prepareForAdditionalSignIn,
|
||||
} from "@/lib/auth-storage";
|
||||
|
||||
type AuthClient = ReturnType<typeof createAuthClient>;
|
||||
|
||||
type PerformAuthResetInput = {
|
||||
authClient: AuthClient;
|
||||
clearActiveAccount: () => Promise<void>;
|
||||
activeAccountId?: string | null;
|
||||
refetchSession?: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
/** Sign out, wipe local auth storage, and return to guest mode for a clean sign-in screen. */
|
||||
export async function performAuthReset({
|
||||
authClient,
|
||||
clearActiveAccount,
|
||||
activeAccountId,
|
||||
refetchSession,
|
||||
}: PerformAuthResetInput): Promise<void> {
|
||||
const accountPrefix = activeAccountId ? authStoragePrefix(activeAccountId) : null;
|
||||
|
||||
try {
|
||||
await authClient.signOut();
|
||||
} catch {
|
||||
// Continue clearing local state even when the server session is already gone.
|
||||
}
|
||||
|
||||
if (accountPrefix) {
|
||||
await clearAuthStorage(accountPrefix);
|
||||
}
|
||||
await clearAuthStorage(GUEST_AUTH_STORAGE_PREFIX);
|
||||
await clearActiveAccount();
|
||||
await refetchSession?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the auth stack is shown, discard stale SecureStore sessions so sign-in starts clean.
|
||||
* Expired account sessions switch back to guest storage; orphaned guest copies are cleared.
|
||||
*/
|
||||
export async function prepareAuthScreenSession(
|
||||
authClient: AuthClient,
|
||||
activeAccountId: string | null,
|
||||
clearActiveAccount: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const session = await authClient.getSession();
|
||||
if (session.data?.user) return;
|
||||
|
||||
if (activeAccountId) {
|
||||
await clearAuthStorage(authStoragePrefix(activeAccountId));
|
||||
await clearActiveAccount();
|
||||
}
|
||||
|
||||
await prepareForAdditionalSignIn();
|
||||
}
|
||||
@@ -74,6 +74,8 @@ export async function migrateAuthStorage(fromPrefix: string, toPrefix: string):
|
||||
copySecureStoreEntry(storageKeyForPrefix(fromPrefix, suffix), storageKeyForPrefix(toPrefix, suffix)),
|
||||
),
|
||||
);
|
||||
|
||||
await clearAuthStorage(fromPrefix);
|
||||
}
|
||||
|
||||
export async function clearAuthStorage(prefix: string): Promise<void> {
|
||||
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { isUnauthorizedError } from "@/lib/trpc-errors";
|
||||
import { isRateLimitError, isUnauthorizedError } from "@/lib/trpc-errors";
|
||||
|
||||
export function createAppQueryClient(onUnauthorized: () => void) {
|
||||
const handleError = (error: unknown) => {
|
||||
@@ -16,7 +16,13 @@ export function createAppQueryClient(onUnauthorized: () => void) {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: (failureCount, error) => {
|
||||
if (isUnauthorizedError(error)) return false;
|
||||
if (isUnauthorizedError(error) || isRateLimitError(error)) return false;
|
||||
return failureCount < 1;
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
retry: (failureCount, error) => {
|
||||
if (isRateLimitError(error)) return false;
|
||||
return failureCount < 1;
|
||||
},
|
||||
},
|
||||
|
||||
+116
-3
@@ -1,8 +1,121 @@
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
|
||||
export function isUnauthorizedError(error: unknown): boolean {
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof TRPCClientError) return error.message;
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === "object" && error !== null && "message" in error) {
|
||||
const message = (error as { message: unknown }).message;
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function errorStatus(error: unknown): number | undefined {
|
||||
if (typeof error !== "object" || error === null || !("status" in error)) return undefined;
|
||||
const status = (error as { status: unknown }).status;
|
||||
return typeof status === "number" ? status : undefined;
|
||||
}
|
||||
|
||||
export function parseRetryAfterSeconds(value: string | number | null | undefined): number | null {
|
||||
if (value == null || value === "") return null;
|
||||
const seconds = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
||||
return Math.ceil(seconds);
|
||||
}
|
||||
|
||||
export function isRateLimitError(error: unknown): boolean {
|
||||
if (errorStatus(error) === 429) return true;
|
||||
|
||||
if (error instanceof TRPCClientError && error.data?.code === "TOO_MANY_REQUESTS") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const message = errorMessage(error).toLowerCase();
|
||||
return (
|
||||
error instanceof TRPCClientError &&
|
||||
(error.data?.code === "UNAUTHORIZED" || error.message === "UNAUTHORIZED")
|
||||
message.includes("too many") ||
|
||||
message.includes("rate limit") ||
|
||||
message.includes("try again later")
|
||||
);
|
||||
}
|
||||
|
||||
export function isUnauthorizedError(error: unknown): boolean {
|
||||
if (isRateLimitError(error)) return false;
|
||||
|
||||
if (error instanceof TRPCClientError) {
|
||||
if (error.data?.code === "UNAUTHORIZED") return true;
|
||||
}
|
||||
|
||||
if (errorStatus(error) === 401) return true;
|
||||
|
||||
const message = errorMessage(error).toLowerCase();
|
||||
return message === "unauthorized" || message.includes("not authenticated");
|
||||
}
|
||||
|
||||
export function formatRateLimitMessage(retryAfterSeconds?: number | null): string {
|
||||
const retryAfter = parseRetryAfterSeconds(retryAfterSeconds ?? null);
|
||||
if (retryAfter != null) {
|
||||
if (retryAfter < 60) {
|
||||
return `Too many attempts. Wait ${retryAfter} second${retryAfter === 1 ? "" : "s"} and try again.`;
|
||||
}
|
||||
const minutes = Math.ceil(retryAfter / 60);
|
||||
return `Too many attempts. Wait about ${minutes} minute${minutes === 1 ? "" : "s"} and try again.`;
|
||||
}
|
||||
return "Too many attempts. Please wait a moment and try again.";
|
||||
}
|
||||
|
||||
export function formatTrpcErrorMessage(error: unknown, fallback = "Something went wrong"): string {
|
||||
if (isRateLimitError(error)) {
|
||||
return formatRateLimitMessage();
|
||||
}
|
||||
if (isUnauthorizedError(error)) {
|
||||
return "Your session expired. Sign in again to continue.";
|
||||
}
|
||||
if (error instanceof TRPCClientError) {
|
||||
return error.message || fallback;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message || fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
type AuthLikeError = {
|
||||
message?: string;
|
||||
status?: number;
|
||||
};
|
||||
|
||||
export function formatAuthErrorMessage(error: AuthLikeError | null | undefined): string {
|
||||
if (!error) return "Something went wrong";
|
||||
|
||||
if (isRateLimitError(error)) {
|
||||
return formatRateLimitMessage();
|
||||
}
|
||||
|
||||
const message = error.message ?? "";
|
||||
if (message.toLowerCase().includes("internal") || message.includes("500")) {
|
||||
return "Server error — is the API running with Postgres? Check beenvoice dev + docker.";
|
||||
}
|
||||
|
||||
return message || "Invalid email or password";
|
||||
}
|
||||
|
||||
export async function readHttpErrorMessage(response: Response): Promise<string> {
|
||||
if (response.status === 429) {
|
||||
const retryAfter = parseRetryAfterSeconds(
|
||||
response.headers.get("x-retry-after") ?? response.headers.get("retry-after"),
|
||||
);
|
||||
return formatRateLimitMessage(retryAfter);
|
||||
}
|
||||
|
||||
const data = (await response.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const message = data.error ?? data.message;
|
||||
if (message && isRateLimitError({ message, status: response.status })) {
|
||||
return formatRateLimitMessage();
|
||||
}
|
||||
|
||||
return message ?? "Something went wrong";
|
||||
}
|
||||
|
||||
+50
-10
@@ -1,25 +1,65 @@
|
||||
import { httpBatchLink } from "@trpc/client";
|
||||
import { createTRPCReact } from "@trpc/react-query";
|
||||
import { useCallback, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { useAccounts } from "@/contexts/AccountsContext";
|
||||
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
||||
import { getAuthCookieHeaders } from "@/lib/auth-cookie";
|
||||
import { performAuthReset } from "@/lib/auth-session";
|
||||
import { createAppQueryClient } from "@/lib/query-client";
|
||||
import type { AppRouter } from "beenvoice/server/api/root";
|
||||
|
||||
export const api = createTRPCReact<AppRouter>();
|
||||
|
||||
export function TRPCProvider({ apiUrl, children }: { apiUrl: string; children: ReactNode }) {
|
||||
export function TRPCProvider({
|
||||
apiUrl,
|
||||
children,
|
||||
}: {
|
||||
apiUrl: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const authClient = useAuthClient();
|
||||
const { authStoragePrefix, activeAccountId, clearActiveAccount } =
|
||||
useAccounts();
|
||||
const { refetch } = useSession();
|
||||
const authStoragePrefixRef = useRef(authStoragePrefix);
|
||||
authStoragePrefixRef.current = authStoragePrefix;
|
||||
|
||||
const mountedRef = useRef(true);
|
||||
const resettingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleUnauthorized = useCallback(async () => {
|
||||
if (!activeAccountId || resettingRef.current || !mountedRef.current) return;
|
||||
|
||||
const session = await authClient.getSession();
|
||||
if (!session.data?.user) {
|
||||
await authClient.signOut();
|
||||
await refetch();
|
||||
if (session.data?.user || !mountedRef.current) return;
|
||||
|
||||
resettingRef.current = true;
|
||||
try {
|
||||
await performAuthReset({
|
||||
authClient,
|
||||
clearActiveAccount,
|
||||
activeAccountId,
|
||||
refetchSession: refetch,
|
||||
});
|
||||
} finally {
|
||||
resettingRef.current = false;
|
||||
}
|
||||
}, [authClient, refetch]);
|
||||
}, [authClient, clearActiveAccount, activeAccountId, refetch]);
|
||||
|
||||
const onUnauthorizedRef = useRef(handleUnauthorized);
|
||||
onUnauthorizedRef.current = handleUnauthorized;
|
||||
@@ -37,10 +77,10 @@ export function TRPCProvider({ apiUrl, children }: { apiUrl: string; children: R
|
||||
url: `${apiUrl}/api/trpc`,
|
||||
transformer: SuperJSON,
|
||||
headers() {
|
||||
const cookie = (
|
||||
authClient as { getCookie?: () => string | null | undefined }
|
||||
).getCookie?.();
|
||||
return cookie ? { cookie } : {};
|
||||
return getAuthCookieHeaders(
|
||||
authClient,
|
||||
authStoragePrefixRef.current,
|
||||
);
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user