feat(mobile): email/password auth flow (connect, login, signup, gate) (#12374)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
SubashMohanandClaude Opus 4.8 authored and GitHub committed 2026-06-25 02:07:11 +00:00
1 parent 40fe0d4cf3
commit 70ff6cd1de
37 files changed
+1227 -269

No files matched your search

+5
View File
@@ -8,3 +8,8 @@
#
# NOTE: EXPO_PUBLIC_* values ship inside the client bundle — they are NOT secret.
EXPO_PUBLIC_API_URL=http://localhost:8080
# API path prefix. Real deployments (cloud / nginx-fronted self-hosted) serve the API
# under `/api` and the proxy strips it; a bare backend (the :8080 above, no nginx) serves
# at the root. Leave empty to hit `:8080` directly in dev; defaults to `/api` when unset.
EXPO_PUBLIC_API_PREFIX=
@@ -17,7 +17,9 @@ import {
getValidToken,
login,
logout,
PostRegisterLoginError,
refreshToken,
register,
} from "@/api/auth/sessionManager";
import { apiFetch, type ApiFetchInit } from "@/api/client";
import { ApiError } from "@/api/errors";
@@ -75,7 +77,7 @@ describe("login", () => {
expect(mockApiFetch).toHaveBeenCalledTimes(1);
const [path, init] = mockApiFetch.mock.calls[0];
expect(path).toBe("/api/auth/mobile/login");
expect(path).toBe("/auth/mobile/login");
expect(init?.method).toBe("POST");
expect(init?.auth).toBe(false);
const body = init?.body as URLSearchParams;
@@ -94,13 +96,61 @@ describe("login", () => {
});
});
describe("register", () => {
it("creates the account as JSON, then logs in to mint the token", async () => {
// register returns no token; the subsequent login does.
mockApiFetch.mockImplementation((path) =>
Promise.resolve(path === "/auth/register" ? undefined : token("tok-new")),
);
await register({ email: "a@example.com", password: "pw" });
const [registerPath, registerInit] = mockApiFetch.mock.calls[0];
expect(registerPath).toBe("/auth/register");
expect(registerInit?.method).toBe("POST");
expect(registerInit?.auth).toBe(false);
expect(registerInit?.body).toEqual({
email: "a@example.com",
password: "pw",
});
expect(mockApiFetch.mock.calls[1][0]).toBe("/auth/mobile/login");
expect(mockSetToken).toHaveBeenCalledWith("tok-new");
});
it("does not log in when account creation fails", async () => {
mockApiFetch.mockRejectedValueOnce(new ApiError({ status: 400 }));
await expect(
register({ email: "taken@example.com", password: "pw" }),
).rejects.toBeInstanceOf(ApiError);
expect(mockApiFetch).toHaveBeenCalledTimes(1);
expect(mockSetToken).not.toHaveBeenCalled();
});
it("flags a post-register login failure distinctly (the account exists)", async () => {
// register OK, auto-login fails (e.g. verification required): account created, not signed in.
mockApiFetch.mockImplementation((path) =>
path === "/auth/register"
? Promise.resolve(undefined)
: Promise.reject(new ApiError({ status: 400 })),
);
await expect(
register({ email: "a@example.com", password: "pw" }),
).rejects.toBeInstanceOf(PostRegisterLoginError);
expect(mockSetToken).not.toHaveBeenCalled();
});
});
describe("logout", () => {
it("revokes server-side, then wipes the token + query cache", async () => {
mockApiFetch.mockResolvedValue(undefined);
await logout();
expect(mockApiFetch).toHaveBeenCalledWith("/api/auth/mobile/logout", {
expect(mockApiFetch).toHaveBeenCalledWith("/auth/mobile/logout", {
method: "POST",
});
expect(mockSetToken).toHaveBeenCalledWith(null);
@@ -185,7 +235,7 @@ describe("refreshToken (single-flight)", () => {
it("does not resurrect the session when a logout completes mid-refresh", async () => {
let resolveRefresh!: (value: BearerTokenResponse) => void;
mockApiFetch.mockImplementation((path) => {
if (path === "/api/auth/mobile/refresh") {
if (path === "/auth/mobile/refresh") {
return new Promise<BearerTokenResponse>((resolve) => {
resolveRefresh = resolve;
});
+73
View File
@@ -0,0 +1,73 @@
// `probeAuthType` validates a *candidate* URL via raw `fetch` — `apiFetch` is bound to
// the already-committed server URL, so it can't reach an unconfirmed instance.
import { getApiPrefix } from "@/api/config";
import type { AuthTypeMetadata } from "@/api/types";
export const ONYX_CLOUD_URL = "https://cloud.onyx.app";
const PROBE_TIMEOUT_MS = 10_000;
// Add https only when no scheme is given (explicit http:// is kept, e.g. localhost);
// strip query/fragment + trailing slash.
export function normalizeServerUrl(input: string): string {
const trimmed = input.trim();
if (trimmed.length === 0) {
throw new Error("Enter your Onyx instance URL.");
}
const withScheme = /^https?:\/\//i.test(trimmed)
? trimmed
: `https://${trimmed}`;
let url: URL;
try {
url = new URL(withScheme);
} catch {
throw new Error("That doesn't look like a valid URL.");
}
if (!url.hostname) {
throw new Error("That doesn't look like a valid URL.");
}
return `${url.origin}${url.pathname}`.replace(/\/+$/, "");
}
// Reject non-Onyx responses (captive portal, HTML error page) so we don't commit a dead URL.
function isAuthTypeMetadata(value: unknown): value is AuthTypeMetadata {
return (
typeof value === "object" &&
value !== null &&
typeof (value as Record<string, unknown>).auth_type === "string"
);
}
export async function probeAuthType(
baseUrl: string,
): Promise<AuthTypeMetadata> {
// fetch has no timeout option; abort after PROBE_TIMEOUT_MS so a dead host can't hang.
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
let res: Response;
try {
res = await fetch(`${baseUrl}${getApiPrefix()}/auth/type`, {
headers: { Accept: "application/json" },
signal: controller.signal,
});
} catch {
throw new Error("Couldn't reach an Onyx instance at that address.");
} finally {
clearTimeout(timeout);
}
if (!res.ok) {
throw new Error("Couldn't reach an Onyx instance at that address.");
}
let body: unknown;
try {
body = await res.json();
} catch {
body = undefined;
}
if (!isAuthTypeMetadata(body)) {
throw new Error("That address doesn't look like an Onyx instance.");
}
return body;
}
+4 -13
View File
@@ -1,8 +1,4 @@
// Provider registry — the single source of truth for which sign-in methods the
// login screen renders. Each descriptor is either a `password` (email/password
// form) or a `browser` provider (system-browser OAuth, wired in PR5). Keeping
// the list here means adding Google / OIDC / SAML / Apple later is a one-line
// addition plus a backend branch, not a screen rewrite.
// Single source of truth for sign-in methods; adding a provider is a one-line addition here.
import type { AuthType, AuthTypeMetadata } from "@/api/types";
export type ProviderId = "password" | "google" | "oidc" | "saml" | "apple";
@@ -12,27 +8,23 @@ export interface ProviderDescriptor {
id: ProviderId;
label: string;
kind: ProviderKind;
// Browser providers redirect through the system browser to this backend
// authorize endpoint. Unused for `password`. (Consumed by browserSso in PR5.)
// Backend authorize endpoint for browser-SSO redirects; unused for `password`.
authorizePath?: string;
}
// V1 ships only email/password. Browser-SSO descriptors (google, …) are added in
// PR5 alongside browserSso.ts.
export const PROVIDER_REGISTRY: Partial<
Record<ProviderId, ProviderDescriptor>
> = {
password: { id: "password", label: "Email", kind: "password" },
};
// AUTH_TYPEs that accept email/password credentials. `cloud` = Google + basic.
// `cloud` accepts password too (Google + basic).
const PASSWORD_AUTH_TYPES: ReadonlySet<AuthType> = new Set<AuthType>([
"basic",
"cloud",
]);
// Providers to render on the login screen, filtered by the connected backend's
// reported configuration (`/api/auth/type`). Returns [] until config loads.
// Filtered by the backend's reported config; returns [] until config loads.
export function visibleProviders(
config: AuthTypeMetadata | undefined,
): ProviderDescriptor[] {
@@ -43,6 +35,5 @@ export function visibleProviders(
if (password && PASSWORD_AUTH_TYPES.has(config.auth_type)) {
providers.push(password);
}
// Browser-SSO providers (filtered by `config.oauth_enabled`) land in PR5.
return providers;
}
+60 -53
View File
@@ -1,12 +1,5 @@
// SessionManager — the single orchestration point for the mobile session
// lifecycle: acquiring the Bearer token (login), revoking it (logout), keeping
// it fresh (refresh — single-flight), and reading the current valid token.
//
// It composes the lower-level seams: `apiFetch` (HTTP), `tokenStore` (keychain),
// the TanStack query cache + MMKV persister (PII purge), and `useSession`
// (coarse status). All session mutations go through here so the
// token-and-cache invariants live in one place. Endpoints come from the mobile
// bearer gateway shipped in PR1 (`backend/onyx/server/auth/mobile.py`).
// Single orchestration point for the mobile session lifecycle; all session
// mutations route through here so the token-and-cache invariants live in one place.
import { apiFetch } from "@/api/client";
import { getToken, setToken } from "@/api/auth/tokenStore";
import { isAuthError } from "@/api/errors";
@@ -19,37 +12,33 @@ export interface BearerTokenResponse {
token_type: string;
}
// V1 supports only email/password. PR5 widens this union with a `browser`
// variant routed through `runBrowserSso` (system-browser PKCE flow).
// V1 supports only email/password; PR5 widens this union with a `browser` SSO variant.
export type LoginMethod = {
kind: "password";
email: string;
password: string;
};
const LOGIN_PATH = "/api/auth/mobile/login";
const REFRESH_PATH = "/api/auth/mobile/refresh";
const LOGOUT_PATH = "/api/auth/mobile/logout";
const LOGIN_PATH = "/auth/mobile/login";
const REFRESH_PATH = "/auth/mobile/refresh";
const LOGOUT_PATH = "/auth/mobile/logout";
// Shared (non-mobile) fastapi-users route; only creates the user.
const REGISTER_PATH = "/auth/register";
// Monotonic session generation, bumped whenever the identity changes (login or
// a local clear). An in-flight refresh captures this when it starts and only
// applies its result if it's unchanged — so a refresh that resolves *after* a
// logout / re-login can't resurrect a cleared session or cross-contaminate a
// new one. (The single-flight guard below only dedupes concurrent refreshes; it
// does nothing to order a refresh against login/logout — that's what this is for.)
// Bumped on every identity change; an in-flight refresh applies its result only
// if this is unchanged, so a refresh resolving after logout/re-login can't
// resurrect a cleared session or cross-contaminate a new one. (Single-flight
// dedupes concurrent refreshes; it does nothing to order them against login/logout.)
let sessionEpoch = 0;
// Drop every trace of the in-memory + on-disk query cache. Done on both login
// and logout so a new identity can never read a previous user's cached data
// (e.g. if the prior session ended by token expiry rather than explicit logout).
// Drop in-memory + on-disk query cache on both login and logout so a new
// identity can never read a previous user's cached data.
async function purgeCache(): Promise<void> {
queryClient.clear();
await persister.removeClient();
}
// fastapi-users `/login` expects an OAuth2 password form (`username`/`password`),
// not JSON. URLSearchParams is sent as-is by `apiFetch` with the correct
// `application/x-www-form-urlencoded` content type.
// fastapi-users `/login` expects an OAuth2 password form (`username`/`password`), not JSON.
function passwordForm(email: string, password: string): URLSearchParams {
const form = new URLSearchParams();
form.set("username", email);
@@ -64,16 +53,46 @@ export async function login(method: LoginMethod): Promise<void> {
body: passwordForm(method.email, method.password),
});
sessionEpoch += 1; // new identity → invalidate any in-flight refresh
// Install the new token BEFORE purging so any query that fires during the
// purge reads the *new* identity's token, never the prior user's (which would
// repopulate the just-cleared cache with the wrong identity's data).
// Install the new token before purging so a query firing mid-purge reads the
// new identity's token, not the prior user's (which would repopulate the cache).
await setToken(res.access_token);
await purgeCache();
useSession.getState().setStatus("authed");
}
// Wipe the session locally: keychain token, in-memory cache, persisted snapshot,
// and status. Used by logout and on an irrecoverable refresh failure.
// register() succeeded but the follow-up auto-login failed (e.g. the instance requires email
// verification): the account exists, so the UI must say "sign in", not "signup failed".
export class PostRegisterLoginError extends Error {
readonly loginError: unknown;
constructor(loginError: unknown) {
super("Account created but automatic sign-in failed");
this.name = "PostRegisterLoginError";
this.loginError = loginError;
}
}
// Create the account, then log in to mint the bearer (register issues no token).
export async function register(params: {
email: string;
password: string;
}): Promise<void> {
await apiFetch<unknown>(REGISTER_PATH, {
method: "POST",
auth: false,
body: { email: params.email, password: params.password },
});
try {
await login({
kind: "password",
email: params.email,
password: params.password,
});
} catch (loginError) {
throw new PostRegisterLoginError(loginError);
}
}
// Wipe the session locally; used by logout and on an irrecoverable refresh failure.
export async function clearLocalSession(): Promise<void> {
sessionEpoch += 1; // invalidate any in-flight refresh so it can't resurrect us
await setToken(null);
@@ -87,8 +106,6 @@ export async function logout(): Promise<void> {
try {
await apiFetch<void>(LOGOUT_PATH, { method: "POST" });
} catch (err) {
// Log so a failed revocation stays traceable, then fall through to the
// local wipe — the on-device token is cleared regardless of the outcome.
console.warn("Mobile logout: server-side token revocation failed", err);
}
await clearLocalSession();
@@ -106,18 +123,15 @@ export function refreshToken(): Promise<string | null> {
const res = await apiFetch<BearerTokenResponse>(REFRESH_PATH, {
method: "POST",
});
// If the session was logged out / replaced while this was in flight,
// discard the refreshed token rather than resurrect or mix identities.
// Session logged out/replaced mid-flight: discard rather than mix identities.
if (sessionEpoch !== startedEpoch) return null;
await setToken(res.access_token);
useSession.getState().setStatus("authed");
return res.access_token;
} catch (err) {
// An auth error means the server already rejected our token
// (revoked/expired) — it's unrecoverable, so drop to a clean logged-out
// state, but only if this is still the same session (don't clobber one
// that already moved on). Transient/network errors are re-thrown so the
// caller keeps the existing token and can retry later.
// Auth error = token already rejected (revoked/expired), unrecoverable: drop
// to logged-out, but only if still the same session. Transient errors re-throw
// so the caller keeps the existing token and can retry later.
if (isAuthError(err)) {
if (sessionEpoch === startedEpoch) await clearLocalSession();
return null;
@@ -130,24 +144,17 @@ export function refreshToken(): Promise<string | null> {
return inFlightRefresh;
}
// Test-only: reset the module-level session state (epoch + single-flight
// handle) so each test starts from a known-clean slate. Without this, a test
// that leaves `inFlightRefresh` non-null would make the next test's
// `refreshToken()` silently reuse the stale promise. No production callers.
// Test-only: reset module-level state so a leaked `inFlightRefresh` can't make
// the next test's `refreshToken()` silently reuse a stale promise.
export function __resetSessionStateForTests(): void {
sessionEpoch = 0;
inFlightRefresh = null;
}
// Returns a usable Bearer token, or null if none. The V1 session token is an
// opaque, server-revocable value (not a JWT), so its expiry can't be read
// client-side; getValidToken therefore returns the stored token as-is and does
// not pre-emptively refresh. If a refresh is already in flight, callers await it
// so everyone ends up with the freshest token (single-flight) — but a *transient*
// refresh failure must not deny them the still-valid stored token, so we fall
// back to it on a thrown error. Proactive foreground / pre-expiry refresh is
// driven externally via `refreshToken` (wired to the AppState focus bridge in a
// later PR).
// The V1 token is opaque (not a JWT), so expiry can't be read client-side; this
// returns the stored token as-is without pre-emptive refresh. If a refresh is
// in flight, callers await it for the freshest token — but a transient failure
// must not deny them the still-valid stored token, so fall back to it on throw.
export async function getValidToken(): Promise<string | null> {
if (inFlightRefresh) {
try {
+6 -29
View File
@@ -1,28 +1,7 @@
// Auth-token storage.
//
// `apiFetch` reads the access token via `getToken()`. `SessionManager` calls
// `setToken(token)` on login and `setToken(null)` on logout.
//
// Tokens are credentials, so they live in the device keychain via
// expo-secure-store — NOT in MMKV (which is reserved for non-secret cache /
// persisted UI state). Tests mock the `expo-secure-store` module.
//
// SECURITY (the TODOs flagged before the auth flow landed are now handled):
// 1. Logout purge — `SessionManager.logout()` clears the in-memory query
// cache AND removes the persisted MMKV snapshot, so the previous user's
// cached /api/me PII does not survive logout.
// 2. Cross-user isolation — login and logout both purge the entire cache, and
// tokens are scoped by normalized instance URL, so user B can never read
// user A's cached data or reuse another instance's bearer token.
// 3. PII at rest — the /api/me query is excluded from the persisted snapshot
// (`dehydrateOptions` in `query/client.ts`), so email/role are never
// written to the unencrypted query-cache MMKV instance. (Full at-rest
// encryption of the cache is a later hardening, paired with expo-crypto.)
// 4. Keychain accessibility — the token is stored THIS_DEVICE_ONLY (kept out
// of iCloud/encrypted backups and device transfers) and WHEN_UNLOCKED
// (readable only while the device is unlocked). The iOS Keychain survives
// app uninstall and has no bulk-clear, so logout deletes the entry
// explicitly.
// expo-secure-store, NOT in MMKV (non-secret cache only). Scoped by instance
// URL so user B can't reuse another instance's bearer token. iOS Keychain
// survives uninstall with no bulk-clear, so logout deletes the entry explicitly.
import * as SecureStore from "expo-secure-store";
import { getBaseUrl } from "@/api/config";
@@ -31,16 +10,14 @@ import { getBaseUrl } from "@/api/config";
const ACCESS_TOKEN_KEY_PREFIX = "onyx.auth.access_token";
const SAFE_KEY_CHAR = /^[A-Za-z0-9.-]$/;
// When the OS keychain will release the stored token (not a login signal):
// WHEN_UNLOCKED — readable only while the device is unlocked.
// THIS_DEVICE_ONLY — never synced to iCloud or restored onto another device.
// THIS_DEVICE_ONLY keeps the token out of iCloud/backups; WHEN_UNLOCKED gates
// reads on an unlocked device.
const TOKEN_KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
};
function encodeSecureStoreKeyPart(value: string): string {
// The server URL scopes the token, but SecureStore rejects ":" and "/".
// Encode unsupported characters into a deterministic safe suffix.
// SecureStore rejects ":" and "/", so encode unsupported chars deterministically.
return Array.from(value)
.map((char) =>
SAFE_KEY_CHAR.test(char)
+3 -9
View File
@@ -1,7 +1,4 @@
// Discovers the connected instance's auth configuration (`/api/auth/type`):
// which login methods to show + password rules. This is a *public* endpoint
// (auth:false) — it's what we call before the user has a token. Keyed by
// serverUrl so switching instances refetches against the new backend.
// Public endpoint (auth:false) called before the user has a token; keyed by serverUrl so switching instances refetches.
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/api/client";
@@ -13,12 +10,9 @@ export function useAuthConfig() {
const serverUrl = useSession((state) => state.serverUrl);
return useQuery({
queryKey: QUERY_KEYS.authType(serverUrl),
// Stay idle until an instance is connected. Before then `getBaseUrl()`
// throws a plain Error (the dev-only EXPO_PUBLIC_API_URL aside), which isn't
// an auth error, so TanStack would retry once and park the query in error
// state instead of simply waiting for a URL.
// Idle until connected: without a URL getBaseUrl() throws a plain Error, which TanStack would retry and park in error state.
enabled: serverUrl !== null,
queryFn: ({ signal }) =>
apiFetch<AuthTypeMetadata>("/api/auth/type", { auth: false, signal }),
apiFetch<AuthTypeMetadata>("/auth/type", { auth: false, signal }),
});
}
+1 -4
View File
@@ -1,7 +1,4 @@
// Email/password sign-in mutation. Thin wrapper over SessionManager so screens
// get the standard TanStack mutation state (isPending / error). On success
// SessionManager has already stored the Bearer token and reset the cache, so
// the caller just needs to navigate (the auth gate reacts to /api/me).
// On success SessionManager has already stored the Bearer token and reset the cache; caller just navigates.
import { useMutation } from "@tanstack/react-query";
import { login } from "@/api/auth/sessionManager";
+10
View File
@@ -0,0 +1,10 @@
// register() creates the account then logs in; on success the token's already stored, so the caller just navigates.
import { useMutation } from "@tanstack/react-query";
import { register } from "@/api/auth/sessionManager";
export function useEmailSignup() {
return useMutation({
mutationFn: (vars: { email: string; password: string }) => register(vars),
});
}
+1 -2
View File
@@ -1,5 +1,4 @@
// Logout mutation. SessionManager.logout best-effort-revokes server-side, then
// wipes the local token + query cache regardless of network outcome.
// Wipes local token + query cache even if the server-side revoke fails.
import { useMutation } from "@tanstack/react-query";
import { logout } from "@/api/auth/sessionManager";
+1 -4
View File
@@ -1,7 +1,4 @@
// Single-flight session-token refresh mutation. Drives proactive refresh on
// foreground resume and reactive refresh on a 401 (both wired in a later PR);
// the single-flight guard lives in SessionManager.refreshToken so concurrent
// triggers collapse to one network call.
// Single-flight guard lives in SessionManager.refreshToken, so concurrent triggers collapse to one network call.
import { useMutation } from "@tanstack/react-query";
import { refreshToken } from "@/api/auth/sessionManager";
+16 -26
View File
@@ -1,6 +1,4 @@
// The single HTTP transport choke point. Every query/mutation goes through
// `apiFetch`, so base-URL resolution, auth-header injection, JSON
// (de)serialization, and error normalization all live in one place.
// Single HTTP choke point: base-URL, auth header, JSON, error normalization.
import { getBaseUrl } from "@/api/config";
import { getToken } from "@/api/auth/tokenStore";
import { ApiError } from "@/api/errors";
@@ -8,8 +6,7 @@ import { ApiError } from "@/api/errors";
export interface ApiFetchInit extends Omit<RequestInit, "body"> {
// Plain objects are JSON-serialized; pass a string/FormData to send as-is.
body?: unknown;
// Inject `Authorization: Bearer <token>` when a token exists. Default true;
// set false for public endpoints.
// Inject `Authorization: Bearer <token>` when present. Set false for public endpoints.
auth?: boolean;
}
@@ -20,10 +17,9 @@ function isBodyInit(body: unknown): body is BodyInit {
body instanceof URLSearchParams ||
body instanceof Blob ||
body instanceof ArrayBuffer ||
// Typed arrays / DataView (Uint8Array, etc.) — without this they'd be
// JSON.stringified into `{"0":1,...}` and sent as application/json,
// silently corrupting binary uploads. (ReadableStream is intentionally
// omitted: RN's fetch doesn't support streamed request bodies.)
// Typed arrays/DataView: without this they'd be JSON.stringified into
// `{"0":1,...}`, corrupting binary uploads. ReadableStream omitted: RN
// fetch can't stream request bodies.
ArrayBuffer.isView(body)
);
}
@@ -46,9 +42,8 @@ async function buildHeaders(
return headers;
}
// The Onyx backend returns errors in a few different JSON shapes depending on
// which handler caught the error (see backend/onyx/main.py +
// backend/onyx/error_handling). We normalize all of them into ApiError:
// Backend error JSON varies by handler (see backend/onyx/main.py +
// backend/onyx/error_handling); normalize all into ApiError:
//
// OnyxError (global handler): { error_code: string, detail: string }
// HTTPException (4xx/5xx log_http_error): { detail: string } (detail may be non-string)
@@ -57,19 +52,17 @@ async function buildHeaders(
// raw FastAPI validation (sub-apps): { detail: [{ loc, msg, type }, ...] }
// non-JSON / empty body: no parseable body
//
// `error_code` (machine code) comes only from OnyxError; the human message
// lives in `detail` (string) or `message`, or — for raw FastAPI validation —
// in the joined `msg` fields of the `detail` array.
// `error_code` comes only from OnyxError; the message lives in `detail`,
// `message`, or the joined `msg` fields of a raw-validation `detail` array.
function extractErrorCode(record: Record<string, unknown>): string | undefined {
return typeof record.error_code === "string" ? record.error_code : undefined;
}
function extractDetail(record: Record<string, unknown>): string | undefined {
// OnyxError / HTTPException string detail.
if (typeof record.detail === "string") return record.detail;
// Onyx validation (422) / ValueError (400) use `message`.
// Onyx validation (422) / ValueError (400).
if (typeof record.message === "string") return record.message;
// Raw FastAPI validation: detail is an array of { loc, msg, type } items.
// Raw FastAPI validation: array of { loc, msg, type }.
if (Array.isArray(record.detail)) {
const messages = record.detail
.map((item) =>
@@ -88,7 +81,7 @@ async function toApiError(res: Response): Promise<ApiError> {
try {
body = await res.json();
} catch {
// Empty or non-JSON body (e.g. a proxy 502/504, or HTML error page).
// Empty or non-JSON body (proxy 502/504, HTML error page).
body = undefined;
}
const record =
@@ -127,8 +120,7 @@ export async function apiFetch<T>(
throw await toApiError(res);
}
// 204 No Content / empty (or whitespace-only) body — resolve as undefined for
// `Promise<void>` callers.
// 204 / empty body resolves as undefined for `Promise<void>` callers.
if (res.status === 204) {
return undefined as T;
}
@@ -136,14 +128,12 @@ export async function apiFetch<T>(
if (text.trim().length === 0) {
return undefined as T;
}
// Guard the parse the same way the error path does: a 2xx with a non-JSON body
// (captive portal / proxy / maintenance HTML) would otherwise throw a raw
// SyntaxError that escapes ApiError normalization (and gets needlessly retried).
// A 2xx with non-JSON body (captive portal / proxy / maintenance HTML) would
// throw a raw SyntaxError that escapes ApiError normalization and gets retried.
try {
return JSON.parse(text) as T;
} catch (parseError) {
// Keep the user-facing detail clean, but preserve the original parser error
// + raw text on `body` for observability (these would otherwise be lost).
// Preserve the parser error + raw text on `body` for observability.
throw new ApiError({
status: res.status,
detail: `Expected a JSON response but received ${
+21 -20
View File
@@ -1,24 +1,25 @@
// Backend connection config.
//
// The base URL is the user-entered Onyx instance URL (cloud or self-hosted),
// captured on the connect screen and persisted via `state/session.ts`. For
// development, `EXPO_PUBLIC_API_URL` is a fallback used only when nothing has
// been stored yet — Expo inlines any `EXPO_PUBLIC_`-prefixed var into the JS
// bundle at build/start time (set it in a gitignored `.env.local`; see
// `.env.example`).
//
// NOTE: `EXPO_PUBLIC_*` ships inside the client bundle — it is NOT a secret.
// Fine for a base URL; never put tokens or keys here.
//
// Resolved LAZILY (per request, inside `apiFetch`) rather than at module load:
// a throw during module evaluation can't be caught by a React error boundary
// (it crashes the bundle), whereas a throw inside the fetch surfaces as a normal
// rejected query the UI can render. Reading the stored URL per request also
// means an instance switch takes effect on the very next call.
// EXPO_PUBLIC_* ships in the client bundle — never put secrets here, base URL only.
// Resolved lazily per request, not at module load: a module-eval throw crashes the
// bundle uncatchably, whereas a throw here surfaces as a rejected query the UI can
// render — and per-request reads make an instance switch take effect on the next call.
import { getStoredServerUrl } from "@/state/session";
// `/api` for nginx-fronted deployments (proxy strips it); set EXPO_PUBLIC_API_PREFIX=""
// for a bare dev backend.
function normalizePrefix(raw: string): string {
const trimmed = raw.replace(/^\/+|\/+$/g, "");
return trimmed ? `/${trimmed}` : "";
}
const API_PREFIX = normalizePrefix(
process.env.EXPO_PUBLIC_API_PREFIX ?? "/api",
);
export function getApiPrefix(): string {
return API_PREFIX;
}
export function getBaseUrl(): string {
// Runtime server URL wins; EXPO_PUBLIC_API_URL is the dev-only fallback.
// EXPO_PUBLIC_API_URL is the dev-only fallback.
const raw = getStoredServerUrl() ?? process.env.EXPO_PUBLIC_API_URL;
if (!raw) {
throw new Error(
@@ -26,6 +27,6 @@ export function getBaseUrl(): string {
"(or set EXPO_PUBLIC_API_URL in mobile/.env.local for development).",
);
}
// Trim a trailing slash so callers always pass paths like "/api/me".
return raw.replace(/\/+$/, "");
// Host + prefix; callers pass bare paths like "/me".
return `${raw.replace(/\/+$/, "")}${API_PREFIX}`;
}
+3 -10
View File
@@ -1,10 +1,5 @@
// Normalized API error model.
//
// The Onyx backend returns errors as `{ error_code, detail }` (the global
// OnyxError handler) or occasionally `{ detail }` / `{ message }`. `apiFetch`
// flattens whichever shape it gets into these typed fields so call sites never
// have to dig into a raw body. Mirrors the intent of web's `FetchError` while
// exposing `status`/`code`/`detail` as first-class fields.
// Backend errors arrive as `{ error_code, detail }`, `{ detail }`, or `{ message }`;
// apiFetch flattens any shape into these typed fields.
interface ApiErrorArgs {
status: number;
@@ -33,9 +28,7 @@ export function isApiError(error: unknown): error is ApiError {
return error instanceof ApiError;
}
// 401 (unauthenticated) / 403 (forbidden) — used both to skip retries and,
// later, to drive the router auth-gate. 402 is included for tier-gated routes,
// matching web's `skipRetryOnAuthError`.
// 401/403, plus 402 for tier-gated routes; skips retries and drives the auth-gate.
export function isAuthError(error: unknown): boolean {
return (
isApiError(error) &&
+1 -13
View File
@@ -1,21 +1,9 @@
// Central TanStack Query key registry (mirrors web's src/lib/swr-keys.ts).
//
// All useQuery / invalidateQueries / setQueryData calls reference these instead
// of inline arrays, so keys stay greppable and consistent. Keys are arrays (the
// TanStack requirement); use `as const` so the literal types are preserved.
// For dynamic keys (per-id endpoints), use a builder function.
export const QUERY_KEYS = {
// ── User ──────────────────────────────────────────────────────────────────
// Keyed by serverUrl so switching instances never serves the previous
// backend's authenticated identity from cache.
me: (serverUrl: string | null) => ["me", serverUrl] as const,
// ── Auth ──────────────────────────────────────────────────────────────────
// Keyed by serverUrl so switching instances refetches the new backend's
// config (and so a null/unset URL has its own cache entry).
// Keyed by serverUrl so switching instances refetches the new backend's config.
authType: (serverUrl: string | null) => ["auth-type", serverUrl] as const,
// Examples for when more endpoints land:
// settings: ["settings"] as const,
// persona: (id: string) => ["persona", id] as const,
};
+4 -12
View File
@@ -1,12 +1,6 @@
// Shared API response types (mirrors web's src/lib/types.ts).
//
// Mobile-local and deliberately minimal — only the fields the app renders. Do
// not mirror web's larger `User` type. If web later shares a DTO for one of
// these via @onyx-ai/shared, reuse that instead (policy: extract-on-proven-reuse).
// Mirrors the backend UserRole enum (backend/onyx/auth/schemas.py) exactly — all
// 7 members. slack_user / ext_perm_user are non-web-login roles unlikely to reach
// a mobile session, but the type must not claim values the API can return are
// impossible (apiFetch casts the response without runtime validation).
// Mobile-local, minimal mirror of web's src/lib/types.ts — only fields the app renders.
// Must list all 7 backend UserRole members: apiFetch casts without runtime validation,
// so the type can't claim API-returnable values are impossible.
export type UserRole =
| "limited"
| "basic"
@@ -23,12 +17,10 @@ export interface CurrentUser {
is_active: boolean;
}
// Mirrors the backend AuthType enum (backend/onyx/configs/constants.py).
// `cloud` = Google OAuth + basic email/password.
export type AuthType = "basic" | "google_oauth" | "oidc" | "saml" | "cloud";
// Mirrors AuthTypeResponse (backend/onyx/server/manage/models.py) — only the
// fields the mobile auth flow reads. Returned by the public `GET /api/auth/type`.
// Subset of AuthTypeResponse read by the mobile auth flow; from public GET /api/auth/type.
export interface AuthTypeMetadata {
auth_type: AuthType;
requires_verification: boolean;
+8
View File
@@ -0,0 +1,8 @@
// Layout for the unauthenticated route group: the connect (instance URL) and login
// screens. Headerless, like the root stack; the AuthGate (root layout) decides when
// these screens are reachable.
import { Stack } from "expo-router";
export default function AuthLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}
+87
View File
@@ -0,0 +1,87 @@
// Connect screen — mobile-only instance picker mirroring the desktop "Root Domain" screen
// (desktop/src/index.html). Probes the URL via `/auth/type` before committing it.
import { router } from "expo-router";
import { useState } from "react";
import { View } from "react-native";
import {
ONYX_CLOUD_URL,
normalizeServerUrl,
probeAuthType,
} from "@/api/auth/instanceUrl";
import { getErrorMessage } from "@/api/errors";
import { AuthScreenShell } from "@/components/auth/AuthScreenShell";
import { InputLayouts, TextInput } from "@/components/form";
import { Button } from "@/components/ui/button";
import { useSession } from "@/state/session";
export default function ConnectScreen() {
const serverUrl = useSession((state) => state.serverUrl);
const setServerUrl = useSession((state) => state.setServerUrl);
// Default to cloud (like desktop): cloud users tap through, self-hosted overwrite.
const [url, setUrl] = useState(serverUrl ?? ONYX_CLOUD_URL);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function connect(candidate: string) {
setError(null);
let normalized: string;
try {
normalized = normalizeServerUrl(candidate);
} catch (validationError) {
setError(getErrorMessage(validationError));
return;
}
setBusy(true);
try {
await probeAuthType(normalized);
} catch (probeError) {
setError(getErrorMessage(probeError));
setBusy(false);
return;
}
// `busy` stays true — the screen unmounts on navigation, so there's nothing to reset.
setServerUrl(normalized);
router.replace("/(auth)/login");
}
return (
<AuthScreenShell title="Connect to Onyx">
<InputLayouts.Vertical
title="Root Domain"
description="The root URL for your Onyx instance"
error={error ?? undefined}
>
<TextInput
value={url}
onChangeText={(text) => {
setUrl(text);
if (error) setError(null);
}}
variant={error ? "error" : "idle"}
placeholder={ONYX_CLOUD_URL}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
inputMode="url"
autoComplete="url"
returnKeyType="go"
onSubmitEditing={() => connect(url)}
/>
</InputLayouts.Vertical>
<View className="mt-16">
<Button
width="full"
disabled={url.trim().length === 0}
loading={busy}
onPress={() => connect(url)}
>
{busy ? "Connecting…" : "Save & Connect"}
</Button>
</View>
</AuthScreenShell>
);
}
+69
View File
@@ -0,0 +1,69 @@
// Login screen — mobile port of web's login UI. Password sign-in via the shared
// EmailPasswordForm; V1 is password-only (the provider registry adds Google/SSO later).
import { router } from "expo-router";
import { ActivityIndicator, View } from "react-native";
import { useAuthConfig } from "@/api/auth/useAuthConfig";
import { visibleProviders } from "@/api/auth/providers";
import { AuthScreenShell } from "@/components/auth/AuthScreenShell";
import { AuthSwitchLink } from "@/components/auth/AuthSwitchLink";
import { EmailPasswordForm } from "@/components/auth/EmailPasswordForm";
import { InputErrorText } from "@/components/form";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import { cn } from "@/lib/utils";
export default function LoginScreen() {
const authConfig = useAuthConfig();
const providers = visibleProviders(authConfig.data);
const hasPassword = providers.some(
(provider) => provider.kind === "password",
);
return (
<AuthScreenShell
title="Welcome to Onyx"
subtitle="Your open source AI platform for work"
footer={
<>
{hasPassword ? (
<AuthSwitchLink
prompt="New to Onyx?"
actionLabel="Create an Account"
onPress={() => router.replace("/(auth)/signup")}
/>
) : null}
<View
className={cn(
"w-full flex-row justify-center",
hasPassword && "mt-12",
)}
>
<Button
variant="action"
prominence="tertiary"
size="md"
onPress={() => router.replace("/(auth)/connect")}
>
Connect to a different instance
</Button>
</View>
</>
}
>
{authConfig.isPending ? (
<ActivityIndicator accessibilityLabel="Loading sign-in options" />
) : authConfig.isError ? (
<InputErrorText>
Couldn&apos;t load sign-in options for this instance.
</InputErrorText>
) : hasPassword ? (
<EmailPasswordForm />
) : (
<Text font="main-content-body" color="text-03">
Sign-in for this instance isn&apos;t supported in the mobile app yet.
</Text>
)}
</AuthScreenShell>
);
}
+49
View File
@@ -0,0 +1,49 @@
// Signup screen — mobile port of web's register UI. Reuses EmailPasswordForm in signup mode;
// password-only in V1, gated on the instance's providers like login.
import { router } from "expo-router";
import { ActivityIndicator } from "react-native";
import { useAuthConfig } from "@/api/auth/useAuthConfig";
import { visibleProviders } from "@/api/auth/providers";
import { AuthScreenShell } from "@/components/auth/AuthScreenShell";
import { AuthSwitchLink } from "@/components/auth/AuthSwitchLink";
import { EmailPasswordForm } from "@/components/auth/EmailPasswordForm";
import { InputErrorText } from "@/components/form";
import { Text } from "@/components/ui/text";
export default function SignupScreen() {
const authConfig = useAuthConfig();
const providers = visibleProviders(authConfig.data);
const hasPassword = providers.some(
(provider) => provider.kind === "password",
);
const passwordMinLength = authConfig.data?.password_min_length ?? 8;
return (
<AuthScreenShell
title="Create account"
subtitle="Get started with Onyx"
footer={
<AuthSwitchLink
prompt="Already have an account?"
actionLabel="Sign In"
onPress={() => router.replace("/(auth)/login")}
/>
}
>
{authConfig.isPending ? (
<ActivityIndicator accessibilityLabel="Loading sign-up options" />
) : authConfig.isError ? (
<InputErrorText>
Couldn&apos;t load sign-up options for this instance.
</InputErrorText>
) : hasPassword ? (
<EmailPasswordForm isSignup passwordMinLength={passwordMinLength} />
) : (
<Text font="main-content-body" color="text-03">
Sign-up for this instance isn&apos;t supported in the mobile app yet.
</Text>
)}
</AuthScreenShell>
);
}
+3
View File
@@ -22,6 +22,7 @@ import {
import { bindAppStateFocus } from "@/query/focus";
import { bindOnlineManager } from "@/query/online";
import { SidebarProvider } from "@/components/sidebar";
import { AuthGate } from "@/components/auth/AuthGate";
// Show the native Onyx splash until the first frame is ready, then reveal the app.
SplashScreen.preventAutoHideAsync();
@@ -74,7 +75,9 @@ export default function RootLayout() {
while still inheriting the vars() theme + safe-area insets. */}
<SidebarProvider>
<StatusBar style="auto" />
<AuthGate>
<Stack screenOptions={{ headerShown: false }} />
</AuthGate>
<PortalHost />
</SidebarProvider>
</PersistQueryClientProvider>
+21 -1
View File
@@ -2,6 +2,9 @@ import { useState } from "react";
import { Pressable, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useLogout } from "@/api/auth/useLogout";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { Button } from "@/components/ui/button";
import { Icon } from "@/components/ui/icon";
import { Text } from "@/components/ui/text";
import { SidebarLayouts, SidebarTab, useSidebar } from "@/components/sidebar";
@@ -33,6 +36,8 @@ function SidebarTrigger() {
export default function Home() {
const { setFolded } = useSidebar();
const [selected, setSelected] = useState("general");
const logout = useLogout();
const { data: user } = useCurrentUser();
function choose(key: string) {
setSelected(key);
@@ -46,11 +51,26 @@ export default function Home() {
<Text font="main-ui-action">Onyx Mobile</Text>
</View>
<View className="flex-1 items-center justify-center px-6">
<View className="flex-1 items-center justify-center px-24">
<Text font="main-content-body" className="text-center text-text-03">
Tap the menu icon to open the sidebar. Tap the backdrop or swipe left
to close.
</Text>
{user ? (
<Text font="secondary-body" color="text-03" className="mt-24">
Signed in as {user.email}
</Text>
) : null}
{/* temporary: log out to test the auth loop */}
<Button
width="full"
prominence="secondary"
loading={logout.isPending}
onPress={() => logout.mutate()}
className="mt-16"
>
{logout.isPending ? "Logging out…" : "Log out"}
</Button>
</View>
<SidebarLayouts.Root foldable>
+64
View File
@@ -0,0 +1,64 @@
// AuthGate — wraps the root navigator and steers between the app and the auth screens
// (decision in `resolveAuthGate`, pure + unit-tested).
//
// Navigation is imperative (`router.replace`), not `<Redirect>`: at the root layout
// <Redirect>'s `useFocusEffect` has no focused route to bind to. `children` (the <Stack>)
// renders in every branch so the navigator stays mounted; the splash overlays it.
import { router, useSegments } from "expo-router";
import * as React from "react";
import { ActivityIndicator, View } from "react-native";
import { isAuthError } from "@/api/errors";
import { AuthUnreachable } from "@/components/auth/AuthUnreachable";
import { resolveAuthGate } from "@/components/auth/authRoute";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { useSession } from "@/state/session";
function AuthSplash() {
return (
<View
accessibilityViewIsModal
className="absolute inset-0 items-center justify-center bg-background-neutral-00"
>
<ActivityIndicator accessibilityLabel="Loading" />
</View>
);
}
export function AuthGate({ children }: { children: React.ReactNode }) {
const serverUrl = useSession((state) => state.serverUrl);
const status = useSession((state) => state.status);
const segments = useSegments();
const { data, error, isError, isFetching, refetch } = useCurrentUser();
const authError = isAuthError(error);
const resolution = resolveAuthGate({
serverUrl,
status,
isAuthed: data !== undefined,
isAuthError: authError,
// `isAuthed` wins, so a background refetch failing over cached identity keeps the app up.
isUnreachable: isError && !authError,
segments,
});
const redirectTo = resolution.kind === "redirect" ? resolution.to : null;
React.useEffect(() => {
if (redirectTo) router.replace(redirectTo);
}, [redirectTo]);
// Overlay: actionable error on a settled failure, else the splash while identity resolves.
const overlay =
resolution.kind === "error" ? (
<AuthUnreachable onRetry={() => refetch()} retrying={isFetching} />
) : resolution.kind !== "render" ? (
<AuthSplash />
) : null;
return (
<>
{children}
{overlay}
</>
);
}
@@ -0,0 +1,59 @@
// Shared chrome for the connect/login/signup screens (mobile take on web's AuthFlowContainer
// + LoginText): logo + heading + the caller's form in a card, with `footer` below it.
import type { ReactNode } from "react";
import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Icon } from "@/components/ui/icon";
import { Text } from "@/components/ui/text";
import SvgOnyxLogo from "@/icons/onyx-logo";
interface AuthScreenShellProps {
title: string;
subtitle?: string;
children: ReactNode;
footer?: ReactNode;
}
export function AuthScreenShell({
title,
subtitle,
children,
footer,
}: AuthScreenShellProps) {
return (
<SafeAreaView className="flex-1 bg-background-neutral-00">
<KeyboardAvoidingView
className="flex-1"
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
{/* No explicit width: `width:"100%"` doesn't resolve inside a ScrollView content view
(it overflowed the screen), so the card stretches via the default `align-items: stretch`. */}
<ScrollView
contentContainerClassName="grow justify-center px-16 py-40"
keyboardShouldPersistTaps="handled"
>
<View className="rounded-16 border border-border-01 bg-background-neutral-01 p-24 shadow-sm">
<Icon
as={SvgOnyxLogo}
size={44}
className="text-theme-primary-05"
/>
<View className="mt-12">
<Text font="heading-h2" color="text-05">
{title}
</Text>
{subtitle ? (
<Text font="main-ui-muted" color="text-03" className="mt-4">
{subtitle}
</Text>
) : null}
</View>
<View className="mt-24">{children}</View>
</View>
{footer ? <View className="mt-24 items-center">{footer}</View> : null}
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
@@ -0,0 +1,30 @@
// Login/signup cross-link (web's footer links). The action is an inner <Text onPress> so it
// reads as one sentence with only the action underlined.
import { Text } from "@/components/ui/text";
interface AuthSwitchLinkProps {
prompt: string;
actionLabel: string;
onPress: () => void;
}
export function AuthSwitchLink({
prompt,
actionLabel,
onPress,
}: AuthSwitchLinkProps) {
return (
<Text font="main-ui-body" color="text-04" className="text-center">
{prompt}{" "}
<Text
font="main-ui-action"
color="text-05"
className="underline"
onPress={onPress}
accessibilityRole="link"
>
{actionLabel}
</Text>
</Text>
);
}
@@ -0,0 +1,50 @@
// AuthGate's error overlay when `/api/me` is unreachable: a retry + change-instance escape so
// a backend blip can't strand the user on a spinner. Self-clears too — the query refetches on
// app focus / reconnect once the instance is reachable again.
import { router } from "expo-router";
import { View } from "react-native";
import { Button } from "@/components/ui/button";
import { Icon } from "@/components/ui/icon";
import { Text } from "@/components/ui/text";
import SvgAlertCircle from "@/icons/alert-circle";
interface AuthUnreachableProps {
onRetry: () => void;
retrying: boolean;
}
export function AuthUnreachable({ onRetry, retrying }: AuthUnreachableProps) {
return (
<View
accessibilityViewIsModal
className="absolute inset-0 justify-center bg-background-neutral-00 px-24"
>
<View className="items-center">
<Icon as={SvgAlertCircle} size={44} className="text-status-error-05" />
<Text font="heading-h3" color="text-05" className="mt-16 text-center">
Can&apos;t reach your Onyx instance
</Text>
<Text font="main-ui-muted" color="text-03" className="mt-4 text-center">
Make sure the server is running and reachable, then try again.
</Text>
</View>
<View className="mt-24">
<Button width="full" loading={retrying} onPress={onRetry}>
{retrying ? "Retrying…" : "Try Again"}
</Button>
<View className="mt-12">
<Button
width="full"
variant="action"
prominence="tertiary"
onPress={() => router.replace("/(auth)/connect")}
>
Change instance
</Button>
</View>
</View>
</View>
);
}
@@ -0,0 +1,166 @@
// Shared email/password form for login + signup (web reuses one form via `isSignup`); signup
// creates the account then logs in (see sessionManager.register).
import { router } from "expo-router";
import { useEffect } from "react";
import { FormProvider, useForm, useWatch } from "react-hook-form";
import { View } from "react-native";
import { PostRegisterLoginError } from "@/api/auth/sessionManager";
import { useEmailLogin } from "@/api/auth/useEmailLogin";
import { useEmailSignup } from "@/api/auth/useEmailSignup";
import { getErrorMessage, isApiError } from "@/api/errors";
import {
InputErrorText,
PasswordInputField,
TextInputField,
} from "@/components/form";
import { Button } from "@/components/ui/button";
import SvgArrowRightCircle from "@/icons/arrow-right-circle";
interface EmailPasswordValues {
email: string;
password: string;
}
// Permissive client-side email shape; the backend is the real validator.
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// Enumeration-safe + handles fastapi-users' 400 for bad creds (not just 401).
function loginErrorMessage(error: unknown): string {
if (isApiError(error) && (error.status === 400 || error.status === 401)) {
return "Invalid email or password.";
}
return getErrorMessage(error, "Couldn't sign in. Please try again.");
}
function signupErrorMessage(error: unknown): string {
// Account was created; only the auto-login failed — point them at sign-in, not retry-signup.
if (error instanceof PostRegisterLoginError) {
return "Account created. Please sign in.";
}
if (isApiError(error)) {
if (error.detail === "REGISTER_USER_ALREADY_EXISTS") {
return "An account already exists with this email.";
}
if (error.status === 429) {
return "Too many requests. Please try again later.";
}
// fastapi-users rejects a weak password as `{ detail: { reason } }`; apiFetch can't
// flatten an object detail, so reach into the raw body for the reason.
const body = error.body as { detail?: { reason?: string } } | undefined;
const reason = body?.detail?.reason;
if (typeof reason === "string" && reason) return reason;
}
return getErrorMessage(
error,
"Couldn't create your account. Please try again.",
);
}
interface EmailPasswordFormProps {
isSignup?: boolean;
passwordMinLength?: number;
}
export function EmailPasswordForm({
isSignup = false,
passwordMinLength = 8,
}: EmailPasswordFormProps) {
const form = useForm<EmailPasswordValues>({
defaultValues: { email: "", password: "" },
mode: "onTouched",
});
const loginMutation = useEmailLogin();
const signupMutation = useEmailSignup();
const mutation = isSignup ? signupMutation : loginMutation;
// Clear the API error on edit. `useWatch` not `form.watch` (React-Compiler safe).
const [email, password] = useWatch({
control: form.control,
name: ["email", "password"],
});
useEffect(() => {
if (mutation.isError) mutation.reset();
// `mutation` is excluded from deps on purpose: it changes when `isError` flips, so
// including it would re-run this and reset the error the instant it's set. Edits only.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email, password]);
const onSubmit = form.handleSubmit((values) => {
mutation.mutate(
{ email: values.email.trim().toLowerCase(), password: values.password },
{ onSuccess: () => router.replace("/") },
);
});
const errorMessage = isSignup ? signupErrorMessage : loginErrorMessage;
return (
<FormProvider {...form}>
<TextInputField<EmailPasswordValues, "email">
name="email"
title="Email Address"
placeholder="email@yourcompany.com"
rules={{
required: "Enter your email.",
pattern: {
value: EMAIL_PATTERN,
message: "Enter a valid email address.",
},
}}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
autoComplete="email"
textContentType="username"
returnKeyType="next"
/>
<View className="mt-12">
<PasswordInputField<EmailPasswordValues, "password">
name="password"
title="Password"
placeholder="●●●●●●●●●●●●●●"
subDescription={
isSignup
? `Must be at least ${passwordMinLength} characters.`
: undefined
}
rules={{
required: "Enter your password.",
...(isSignup && {
minLength: {
value: passwordMinLength,
message: `Must be at least ${passwordMinLength} characters.`,
},
}),
}}
autoComplete={isSignup ? "new-password" : "current-password"}
textContentType={isSignup ? "newPassword" : "password"}
returnKeyType="go"
onSubmitEditing={() => onSubmit()}
/>
</View>
{mutation.isError ? (
<View className="mt-12">
<InputErrorText>{errorMessage(mutation.error)}</InputErrorText>
</View>
) : null}
<View className="mt-16">
<Button
width="full"
rightIcon={SvgArrowRightCircle}
loading={mutation.isPending}
onPress={onSubmit}
>
{isSignup
? mutation.isPending
? "Creating account…"
: "Create Account"
: mutation.isPending
? "Signing in…"
: "Sign In"}
</Button>
</View>
</FormProvider>
);
}
@@ -0,0 +1,194 @@
// resolveAuthGate decisions as pure logic — every branch + the precedence between them.
import { describe, expect, it } from "@jest/globals";
import {
type AuthGateInput,
resolveAuthGate,
} from "@/components/auth/authRoute";
const HOME: readonly string[] = [];
const PROTECTED: readonly string[] = ["chat"];
const CONNECT: readonly string[] = ["(auth)", "connect"];
const LOGIN: readonly string[] = ["(auth)", "login"];
const SIGNUP: readonly string[] = ["(auth)", "signup"];
// Defaults to the logged-out, unresolved state; each test overrides what it exercises.
function input(overrides: Partial<AuthGateInput>): AuthGateInput {
return {
serverUrl: "https://cloud.onyx.app",
status: "loading",
isAuthed: false,
isAuthError: false,
isUnreachable: false,
segments: HOME,
...overrides,
};
}
describe("resolveAuthGate — no instance connected", () => {
it("renders the connect screen when already on it", () => {
expect(
resolveAuthGate(input({ serverUrl: null, segments: CONNECT })),
).toEqual({ kind: "render" });
});
it("redirects to connect from the login screen", () => {
expect(
resolveAuthGate(input({ serverUrl: null, segments: LOGIN })),
).toEqual({ kind: "redirect", to: "/(auth)/connect" });
});
it("redirects to connect from the signup screen (must connect first)", () => {
expect(
resolveAuthGate(input({ serverUrl: null, segments: SIGNUP })),
).toEqual({ kind: "redirect", to: "/(auth)/connect" });
});
it("redirects to connect from a protected route", () => {
expect(resolveAuthGate(input({ serverUrl: null, segments: HOME }))).toEqual(
{ kind: "redirect", to: "/(auth)/connect" },
);
});
it("takes precedence over an (impossible) authenticated state", () => {
expect(
resolveAuthGate(
input({ serverUrl: null, isAuthed: true, segments: HOME }),
),
).toEqual({ kind: "redirect", to: "/(auth)/connect" });
});
});
describe("resolveAuthGate — explicitly logged out (status anon)", () => {
it("redirects a protected route to login without a /me round-trip", () => {
expect(
resolveAuthGate(input({ status: "anon", segments: PROTECTED })),
).toEqual({ kind: "redirect", to: "/(auth)/login" });
});
it("renders inside the auth group", () => {
expect(resolveAuthGate(input({ status: "anon", segments: LOGIN }))).toEqual(
{
kind: "render",
},
);
});
it("wins over stale cached identity (a just-logged-out user isn't shown the app)", () => {
expect(
resolveAuthGate(
input({ status: "anon", isAuthed: true, segments: PROTECTED }),
),
).toEqual({ kind: "redirect", to: "/(auth)/login" });
});
it("still routes to connect first when no instance is set", () => {
expect(
resolveAuthGate(
input({ status: "anon", serverUrl: null, segments: PROTECTED }),
),
).toEqual({ kind: "redirect", to: "/(auth)/connect" });
});
});
describe("resolveAuthGate — authenticated", () => {
it("renders protected routes", () => {
expect(
resolveAuthGate(input({ isAuthed: true, segments: PROTECTED })),
).toEqual({ kind: "render" });
});
it("bounces off the login screen into the app", () => {
expect(resolveAuthGate(input({ isAuthed: true, segments: LOGIN }))).toEqual(
{ kind: "redirect", to: "/" },
);
});
it("bounces off the connect screen into the app", () => {
expect(
resolveAuthGate(input({ isAuthed: true, segments: CONNECT })),
).toEqual({ kind: "redirect", to: "/" });
});
it("bounces off the signup screen into the app", () => {
expect(
resolveAuthGate(input({ isAuthed: true, segments: SIGNUP })),
).toEqual({ kind: "redirect", to: "/" });
});
});
describe("resolveAuthGate — definitively unauthenticated (401)", () => {
it("redirects a protected route to login", () => {
expect(
resolveAuthGate(input({ isAuthError: true, segments: PROTECTED })),
).toEqual({ kind: "redirect", to: "/(auth)/login" });
});
it("renders the login screen", () => {
expect(
resolveAuthGate(input({ isAuthError: true, segments: LOGIN })),
).toEqual({ kind: "render" });
});
it("renders the connect screen (reachable for switching instances)", () => {
expect(
resolveAuthGate(input({ isAuthError: true, segments: CONNECT })),
).toEqual({ kind: "render" });
});
it("renders the signup screen", () => {
expect(
resolveAuthGate(input({ isAuthError: true, segments: SIGNUP })),
).toEqual({ kind: "render" });
});
});
describe("resolveAuthGate — instance unreachable (settled non-auth /me failure)", () => {
it("shows the error screen on a protected route", () => {
expect(
resolveAuthGate(input({ isUnreachable: true, segments: PROTECTED })),
).toEqual({ kind: "error" });
});
it("renders inside the auth group (those screens own their errors)", () => {
expect(
resolveAuthGate(input({ isUnreachable: true, segments: CONNECT })),
).toEqual({ kind: "render" });
expect(
resolveAuthGate(input({ isUnreachable: true, segments: LOGIN })),
).toEqual({ kind: "render" });
});
it("yields to cached identity (authed wins over a failed background refetch)", () => {
expect(
resolveAuthGate(
input({ isAuthed: true, isUnreachable: true, segments: PROTECTED }),
),
).toEqual({ kind: "render" });
});
it("yields to a decisive auth error (401 routes to login, not the error screen)", () => {
expect(
resolveAuthGate(
input({ isAuthError: true, isUnreachable: true, segments: PROTECTED }),
),
).toEqual({ kind: "redirect", to: "/(auth)/login" });
});
});
describe("resolveAuthGate — identity not yet resolved", () => {
it("shows the splash on a protected route", () => {
expect(resolveAuthGate(input({ segments: PROTECTED }))).toEqual({
kind: "splash",
});
});
it("renders inside the auth group (user is mid-connect/login)", () => {
expect(resolveAuthGate(input({ segments: LOGIN }))).toEqual({
kind: "render",
});
expect(resolveAuthGate(input({ segments: CONNECT }))).toEqual({
kind: "render",
});
});
});
+66
View File
@@ -0,0 +1,66 @@
// AuthGate's routing decision as a pure function, so the branching is unit-tested
// without rendering RN. `AuthGate.tsx` wires live session + /api/me state in.
import type { SessionStatus } from "@/state/session";
const AUTH_GROUP = "(auth)";
export type AuthTarget = "/(auth)/connect" | "/(auth)/login" | "/";
export type AuthGateResolution =
| { kind: "render" }
| { kind: "splash" }
| { kind: "error" }
| { kind: "redirect"; to: AuthTarget };
export interface AuthGateInput {
serverUrl: string | null;
// `"anon"` = explicit logout / rejected token; the gate treats it as a decisive "logged out"
// with no `/api/me` round-trip, so logout redirects instantly and works offline.
status: SessionStatus;
isAuthed: boolean;
// `/api/me` failed with 401/402/403 — a decisive "logged out".
isAuthError: boolean;
// `/api/me` settled in a non-auth failure (backend unreachable); not "still loading" — retries are done.
isUnreachable: boolean;
// expo-router route segments, e.g. ["(auth)", "connect"].
segments: readonly string[];
}
export function resolveAuthGate(input: AuthGateInput): AuthGateResolution {
const { serverUrl, status, isAuthed, isAuthError, isUnreachable, segments } =
input;
const inAuthGroup = segments[0] === AUTH_GROUP;
const authScreen = inAuthGroup ? segments[1] : undefined;
if (serverUrl === null) {
return authScreen === "connect"
? { kind: "render" }
: { kind: "redirect", to: "/(auth)/connect" };
}
// Decisive logout → straight to login (see `status` above).
if (status === "anon") {
return inAuthGroup
? { kind: "render" }
: { kind: "redirect", to: "/(auth)/login" };
}
if (isAuthed) {
return inAuthGroup ? { kind: "redirect", to: "/" } : { kind: "render" };
}
if (isAuthError) {
return inAuthGroup
? { kind: "render" }
: { kind: "redirect", to: "/(auth)/login" };
}
// Won't self-resolve: error+retry screen on protected routes instead of an endless splash.
// Auth screens own their errors, so render them as usual.
if (isUnreachable) {
return inAuthGroup ? { kind: "render" } : { kind: "error" };
}
// Still resolving: splash on protected routes, render in the auth group (mid-connect/login).
return inAuthGroup ? { kind: "render" } : { kind: "splash" };
}
+26 -7
View File
@@ -3,8 +3,9 @@
// and sizing live in button.styles. Web "hover" maps to RN "pressed". Spacing
// uses margins, not `gap-*` (unreliable in RN/NativeWind — see SidebarTab).
// `children` is plain string; web's RichStr/markdown is intentionally unsupported.
import { Pressable, View } from "react-native";
import { ActivityIndicator, Pressable, View } from "react-native";
import { router, type Href } from "expo-router";
import { cssInterop } from "nativewind";
import type { InteractiveContract } from "@onyx-ai/shared/contracts";
import { cn } from "@/lib/utils";
@@ -20,6 +21,12 @@ import {
type ButtonWidth,
} from "@/components/ui/button.styles";
// ActivityIndicator ignores `style.color`; bridge the text-color class onto its `color` prop so
// the spinner matches the label (like text-input's placeholder).
const Spinner = cssInterop(ActivityIndicator, {
className: { target: false, nativeStyleToProp: { color: "color" } },
}) as React.ComponentType<{ className?: string; size?: "small" | "large" }>;
type ButtonBaseProps = InteractiveContract & {
/** Size preset. @default "lg" */
size?: ButtonSize;
@@ -30,6 +37,8 @@ type ButtonBaseProps = InteractiveContract & {
* @default "rest"
*/
interaction?: ButtonInteraction;
/** Shows a spinner, dims to the disabled look, and blocks presses. @default false */
loading?: boolean;
rightIcon?: IconFunctionComponent;
onPress?: () => void;
/** Navigates here on press (expo-router). */
@@ -62,6 +71,7 @@ function Button({
width = "fit",
interaction = "rest",
disabled = false,
loading = false,
accessibilityLabel,
icon,
rightIcon,
@@ -74,24 +84,28 @@ function Button({
const hasLabel = children != null;
function handlePress() {
// disabled Pressable already blocks onPress — no guard needed
// disabled/loading Pressable already blocks onPress — no guard needed
onPress?.();
if (href != null) router.navigate(href);
}
return (
<Pressable
disabled={disabled}
disabled={disabled || loading}
onPress={handlePress}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityState={{ disabled }}
accessibilityState={{ disabled: disabled || loading, busy: loading }}
// RN stretches flex children on the cross axis, so `fit` needs `self-start`
// to shrink-wrap like web's `w-fit` (RN has no `fit-content`).
className={cn(width === "full" ? "w-full" : "self-start", className)}
>
{({ pressed }) => {
const state = resolveButtonState(disabled, interaction, pressed);
const state = resolveButtonState(
disabled || loading,
interaction,
pressed,
);
const colors = BUTTON_COLORS[variant][prominence][state];
return (
<View
@@ -106,7 +120,12 @@ function Button({
colors.border,
)}
>
{icon ? (
{loading ? (
// Replaces the leading icon while working; rightIcon is suppressed below.
<View className={cn("items-center justify-center", spec.iconPad)}>
<Spinner size="small" className={colors.fg} />
</View>
) : icon ? (
<View className={cn("items-center justify-center", spec.iconPad)}>
<Icon as={icon} size={spec.iconSize} className={colors.fg} />
</View>
@@ -126,7 +145,7 @@ function Button({
</Text>
) : null}
{rightIcon ? (
{!loading && rightIcon ? (
<View
className={cn(
"items-center justify-center",
+4 -8
View File
@@ -1,9 +1,4 @@
// Fetches the authenticated user from `/api/me`.
//
// Self-contained (key + queryFn inline), mirroring web's hook-per-file SWR
// pattern. Returns the standard TanStack result: `{ data, error, isPending, ... }`.
// `error` is an `ApiError`; until the login flow lands this resolves to a 403,
// which the auth-aware retry in `query/client.ts` does not retry.
// Fetches the authenticated user from `/api/me` — the auth gate's identity probe.
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/api/client";
@@ -15,7 +10,8 @@ export function useCurrentUser() {
const serverUrl = useSession((state) => state.serverUrl);
return useQuery({
queryKey: QUERY_KEYS.me(serverUrl),
// `signal` is forwarded so `cancelQueries` can abort the in-flight request.
queryFn: ({ signal }) => apiFetch<CurrentUser>("/api/me", { signal }),
// Idle until connected: no serverUrl → `getBaseUrl()` throws (not a 401). Like `useAuthConfig`.
enabled: serverUrl !== null,
queryFn: ({ signal }) => apiFetch<CurrentUser>("/me", { signal }),
});
}
+24
View File
@@ -0,0 +1,24 @@
import Svg, { Path } from "react-native-svg";
import type { IconProps } from "@/icons/types";
// RN port of web Opal `SvgArrowRightCircle` (web/lib/opal/src/icons/arrow-right-circle.tsx).
const SvgArrowRightCircle = ({ size = 16, ...props }: IconProps) => (
<Svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
{...props}
>
<Path
d="M7.99999 10.6667L10.6667 8.00001M10.6667 8.00001L7.99999 5.33334M10.6667 8.00001L5.33333 8.00001M14.6667 8.00001C14.6667 11.6819 11.6819 14.6667 7.99999 14.6667C4.3181 14.6667 1.33333 11.6819 1.33333 8.00001C1.33333 4.31811 4.3181 1.33334 7.99999 1.33334C11.6819 1.33334 14.6667 4.31811 14.6667 8.00001Z"
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
export default SvgArrowRightCircle;
+22
View File
@@ -0,0 +1,22 @@
import Svg, { Path } from "react-native-svg";
import type { IconProps } from "@/icons/types";
// RN port of web Opal `SvgOnyxLogo`. Paths use `currentColor` (RN can't read the CSS var
// web fills with), tinted via `<Icon as={SvgOnyxLogo} className="text-theme-primary-05" />`.
const SvgOnyxLogo = ({ size = 16, ...props }: IconProps) => (
<Svg
width={size}
height={size}
viewBox="0 0 64 64"
fill="currentColor"
{...props}
>
<Path d="M10.4014 13.25L18.875 32L10.3852 50.75L2 32L10.4014 13.25Z" />
<Path d="M53.5264 13.25L62 32L53.5102 50.75L45.125 32L53.5264 13.25Z" />
<Path d="M32 45.125L50.75 53.5625L32 62L13.25 53.5625L32 45.125Z" />
<Path d="M32 2L50.75 10.4375L32 18.875L13.25 10.4375L32 2Z" />
</Svg>
);
export default SvgOnyxLogo;
+12 -29
View File
@@ -1,4 +1,3 @@
// TanStack Query client + MMKV-backed persister.
import { QueryClient, type DehydrateOptions } from "@tanstack/react-query";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
@@ -6,27 +5,20 @@ import { makeMmkvStorage, queryStorage } from "@/state/storage";
import { isAuthError } from "@/api/errors";
import { QUERY_KEYS } from "@/api/query-keys";
// How long a restored MMKV snapshot stays valid (24h). gcTime is pinned to this
// below: an inactive query must live in the in-memory cache at least as long as
// the persist window, otherwise it's garbage-collected after the default 5 min,
// the persister rewrites an empty snapshot, and the 24h offline cache silently
// collapses to ~5 min.
// gcTime is pinned to this below; if an inactive query is GC'd before the persist
// window, the persister rewrites an empty snapshot and the offline cache collapses.
export const persistMaxAge = 1000 * 60 * 60 * 24; // 24h
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // 30s — fresh enough to skip refetch storms
gcTime: persistMaxAge, // keep inactive queries for the full persist window
// Don't retry auth/tier errors (401/402/403) — retrying just spams the
// backend with requests that can't succeed without re-auth. Otherwise
// retry once. Mirrors web's `skipRetryOnAuthError`.
staleTime: 30_000,
gcTime: persistMaxAge, // must cover the persist window, see persistMaxAge
// Don't retry auth/tier errors (401/402/403); they can't succeed without re-auth.
retry: (failureCount, error) => !isAuthError(error) && failureCount < 1,
// Refetch stale data when the app returns to the foreground. On RN this is
// driven by the AppState -> focusManager bridge in `query/focus.ts`; this
// flag must stay true (its default) or that bridge becomes a no-op.
// Must stay true or the AppState->focusManager bridge in query/focus.ts no-ops.
refetchOnWindowFocus: true,
refetchOnReconnect: true, // refetch stale data when connectivity returns
refetchOnReconnect: true,
},
},
});
@@ -35,19 +27,10 @@ export const persister = createSyncStoragePersister({
storage: makeMmkvStorage(queryStorage),
});
// Query-key prefixes whose data is identity PII (or otherwise must not be
// written to disk). The MMKV query-cache snapshot is unencrypted: a secure
// encryption key would have to be fetched asynchronously from the keychain
// before the synchronous MMKV instance is created — fragile — so instead we keep
// PII out of the persisted snapshot entirely. /api/me (email, role) is the only
// such query today. The in-memory cache still holds it for the session; only
// persistence is skipped, so a relaunch refetches it (and the auth gate shows a
// brief splash).
//
// Matched structurally, segment by segment (see `isNonPersistedKey`), so the
// whole `["me", serverUrl]` family is excluded for any serverUrl while an
// unrelated key such as `["me-preferences"]` is not. The trailing serverUrl
// varies per instance, so only the leading entity segment forms the prefix.
// Key prefixes whose data must not hit the unencrypted MMKV snapshot (PII).
// /api/me (email, role) is the only one today; in-memory cache still holds it,
// so a relaunch just refetches. Only the leading entity segment forms the prefix
// since the trailing serverUrl varies per instance.
const NON_PERSISTED_KEY_PREFIXES: readonly (readonly unknown[])[] = [
[QUERY_KEYS.me(null)[0]],
];
@@ -61,7 +44,7 @@ function isNonPersistedKey(queryKey: readonly unknown[]): boolean {
export const dehydrateOptions: DehydrateOptions = {
shouldDehydrateQuery: (query) => {
if (isNonPersistedKey(query.queryKey)) return false;
// Otherwise keep the library default: only persist successful queries.
// Library default: only persist successful queries.
return query.state.status === "success";
},
};
+1 -4
View File
@@ -1,10 +1,7 @@
// Bridges React Native's AppState into TanStack Query's focusManager so that
// queries can refetch when the app returns to the foreground (the mobile
// equivalent of browser window-focus refetching). Zero extra dependencies.
// Bridges RN AppState into TanStack focusManager: the mobile equivalent of browser window-focus refetching.
import { AppState, type AppStateStatus } from "react-native";
import { focusManager } from "@tanstack/react-query";
// Call once at app startup; returns an unsubscribe for cleanup.
export function bindAppStateFocus(): () => void {
function onChange(status: AppStateStatus): void {
focusManager.setFocused(status === "active");
+3 -7
View File
@@ -1,15 +1,11 @@
// Bridges device connectivity (via @react-native-community/netinfo) into
// TanStack Query's onlineManager. Without this, TanStack assumes it's always
// online: offline queries error and retry in a loop, and `refetchOnReconnect`
// has nothing to trigger it.
// Without this, TanStack assumes always-online: offline queries retry-loop and refetchOnReconnect never fires.
import NetInfo from "@react-native-community/netinfo";
import { onlineManager } from "@tanstack/react-query";
// Call once at app startup; returns an unsubscribe for cleanup.
// Call once at app startup.
export function bindOnlineManager(): () => void {
return NetInfo.addEventListener((state) => {
// `isInternetReachable` is null while NetInfo is still determining
// reachability — treat that as online to avoid false offline flashes.
// isInternetReachable is null until NetInfo resolves; treat as online to avoid false offline flashes.
onlineManager.setOnline(
Boolean(state.isConnected) && state.isInternetReachable !== false,
);
+7 -15
View File
@@ -1,15 +1,10 @@
// Auth-session store: which Onyx instance we point at + a coarse auth status.
// Auth-session store: the Onyx instance URL we target + a coarse auth status.
//
// `serverUrl` is the user-entered base URL of their Onyx instance (cloud or
// self-hosted) — the runtime replacement for the build-time EXPO_PUBLIC_API_URL.
// It must be readable *synchronously* by the HTTP layer (`config.ts#getBaseUrl`,
// called once per request), so it is persisted directly to MMKV under a
// dedicated key rather than through zustand's persist envelope. The synchronous
// `getStoredServerUrl()` is the read path `getBaseUrl` uses.
//
// `status` is a coarse, in-memory signal SessionManager flips on login/logout
// for immediate UI feedback. The authoritative identity check remains
// `useCurrentUser` (/api/me); `status` is intentionally NOT persisted.
// `serverUrl` persists straight to MMKV (not via zustand's persist) because the HTTP
// layer (`config.ts#getBaseUrl`) reads it *synchronously*, once per request, through
// `getStoredServerUrl`. `status` is in-memory only: `useCurrentUser` (/api/me) is the
// authoritative identity check, except `"anon"` (explicit logout / rejected token), which
// the auth gate treats as a decisive logged-out signal.
import { create } from "zustand";
import { appStorage } from "@/state/storage";
@@ -18,8 +13,6 @@ const SERVER_URL_KEY = "onyx.session.server_url";
export type SessionStatus = "loading" | "authed" | "anon";
// Synchronous read of the persisted server URL. Used by `getBaseUrl` (which
// runs outside React, per request) and to seed the store's initial value.
export function getStoredServerUrl(): string | null {
return appStorage.getString(SERVER_URL_KEY) ?? null;
}
@@ -36,8 +29,7 @@ export const useSession = create<SessionState>((set) => ({
serverUrl: getStoredServerUrl(),
setStatus: (status) => set({ status }),
setServerUrl: (serverUrl) => {
// Persist immediately so `getBaseUrl` (which reads MMKV directly) sees the
// new URL on the very next request, before any React re-render settles.
// Persist before the React update so `getBaseUrl` sees it on the very next request.
if (serverUrl === null) {
appStorage.remove(SERVER_URL_KEY);
} else {