mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
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:
1 parent
40fe0d4cf3
commit
70ff6cd1de
37 files changed
+1227
-269
No files matched your search
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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,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,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
@@ -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
@@ -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}`;
|
||||
}
|
||||
@@ -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,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
@@ -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;
|
||||
|
||||
@@ -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 }} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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't supported in the mobile app yet.
|
||||
</Text>
|
||||
)}
|
||||
</AuthScreenShell>
|
||||
);
|
||||
}
|
||||
@@ -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'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't supported in the mobile app yet.
|
||||
</Text>
|
||||
)}
|
||||
</AuthScreenShell>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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,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");
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user