feat(auth): add remembered local login to the frontend

This commit is contained in:
2026-08-17 04:52:39 +02:00
parent 8c67cb75dc
commit 202822f3ba
36 changed files with 2649 additions and 163 deletions
+158 -10
View File
@@ -1,15 +1,137 @@
import { backendBaseUrl as BASE, joinBackendPath } from "./runtime-config";
import {
clearAuthStateIfCurrent,
getAuthGeneration,
getAuthState,
} from "../auth/authState";
const MAX_ERROR_BODY_BYTES = 8 * 1024;
const safeErrorCodes = new Set([
"auth_forbidden", "auth_invalid_credentials", "auth_not_authorized", "auth_unavailable",
"auth_not_implemented", "authentication_required", "invalid_credentials", "login_rate_limited",
"csrf_failed", "csrf_invalid", "dwh_unreachable", "model_unavailable",
"workspace_invalid", "binding_missing", "workspace_not_activatable", "workspace_stale",
"git_unavailable", "git_auth_failed", "git_non_fast_forward", "connector_unavailable",
"semantic_index_incompatible", "pi_management_forbidden", "pi_management_unavailable",
"pi_management_invalid_config", "pi_management_write_failed",
]);
type SafeErrorPayload = {
code: string;
};
const localCodeMessages: Record<string, string> = {
auth_forbidden: "Access is not permitted.",
auth_invalid_credentials: "Invalid username or password.",
auth_not_authorized: "Access is not permitted.",
auth_unavailable: "Authentication is temporarily unavailable. Try again.",
auth_not_implemented: "This sign-in method is not available.",
authentication_required: "Please sign in to continue.",
invalid_credentials: "Invalid username or password.",
login_rate_limited: "Too many sign-in attempts. Try again later.",
csrf_failed: "The security check failed. Please retry.",
csrf_invalid: "The security check failed. Please retry.",
dwh_unreachable: "The database is unreachable. Please retry.",
model_unavailable: "The model provider is unavailable. Please retry.",
workspace_invalid: "The workspace configuration is invalid.",
binding_missing: "The workspace is missing a required binding.",
workspace_not_activatable: "The workspace cannot be activated.",
workspace_stale: "The workspace has changed. Refresh and try again.",
git_unavailable: "The workspace repository is unavailable.",
git_auth_failed: "The workspace repository could not be authenticated.",
git_non_fast_forward: "The workspace repository has moved. Refresh and try again.",
connector_unavailable: "A workspace connector is unavailable.",
semantic_index_incompatible: "The workspace semantic index is incompatible.",
pi_management_forbidden: "Pi management is not permitted",
pi_management_unavailable: "Pi management is unavailable.",
pi_management_invalid_config: "The Pi configuration is invalid.",
pi_management_write_failed: "The Pi configuration could not be saved.",
};
const localStatusMessages: Record<number, string> = {
401: "Please sign in to continue.",
403: "Access is not permitted.",
404: "The requested resource was not found.",
409: "The request conflicts with current workspace state.",
429: "Too many requests. Try again later.",
500: "Request failed. Please try again.",
502: "The service is unavailable. Please retry.",
503: "The service is temporarily unavailable. Please retry.",
};
const GENERIC_ERROR_MESSAGE = "Request failed. Please try again.";
function localErrorMessage(status: number, code?: string): string {
return (code && localCodeMessages[code]) || localStatusMessages[status] || GENERIC_ERROR_MESSAGE;
}
/**
* Error thrown for non-2xx responses. `.message` stays "<status> <body>" for
* backward compatibility; `.status` and `.payload` (parsed JSON body, if any)
* let callers branch on a specific failure — e.g. a `code: "dwh_unreachable"`.
* Error thrown for non-2xx responses. The message contains only status and a
* whitelisted error code; `.payload` contains a bounded, sanitized JSON shape.
*/
export class ApiError extends Error {
constructor(readonly status: number, readonly bodyText: string, readonly payload: unknown) {
super(`${status} ${bodyText}`);
constructor(
readonly status: number,
readonly bodyText: string,
readonly payload: SafeErrorPayload | undefined,
) {
super(localErrorMessage(status, payload?.code));
this.name = "ApiError";
}
get code(): string | undefined {
return this.payload?.code;
}
}
export function apiErrorMessage(error: unknown): string {
return error instanceof ApiError ? error.message : GENERIC_ERROR_MESSAGE;
}
function parseSafeErrorPayload(text: string, truncated: boolean): SafeErrorPayload | undefined {
if (truncated || text.length === 0) return undefined;
let parsed: unknown;
try { parsed = JSON.parse(text); } catch { return undefined; }
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
const source = parsed as Record<string, unknown>;
if (typeof source.code !== "string" || !safeErrorCodes.has(source.code)) return undefined;
return { code: source.code };
}
async function readBoundedText(response: Response): Promise<{ text: string; truncated: boolean }> {
const reader = response.body?.getReader();
if (!reader) return { text: "", truncated: false };
const decoder = new TextDecoder();
let text = "";
let bytes = 0;
let truncated = false;
try {
while (true) {
const next = await reader.read();
if (next.done) break;
const remaining = MAX_ERROR_BODY_BYTES - bytes;
if (remaining <= 0) {
truncated = true;
await reader.cancel();
break;
}
const chunk = next.value.byteLength > remaining ? next.value.slice(0, remaining) : next.value;
bytes += chunk.byteLength;
text += decoder.decode(chunk, { stream: next.value.byteLength <= remaining });
if (next.value.byteLength > remaining) {
truncated = true;
await reader.cancel();
break;
}
}
} catch (error) {
try { await reader.cancel(); } catch { /* preserve the original read failure */ }
throw error;
} finally {
text += decoder.decode();
try { reader.releaseLock(); } catch { /* a completed browser reader may already be released */ }
}
return { text, truncated };
}
function requestHeaders(init: RequestInit | undefined): Headers {
@@ -27,16 +149,42 @@ function requestHeaders(init: RequestInit | undefined): Headers {
}
async function request(path: string, init?: RequestInit): Promise<Response> {
const res = await fetch(joinBackendPath(BASE, path), { ...init, headers: requestHeaders(init) });
const url = joinBackendPath(BASE, path);
assertSameOriginRequestUrl(url);
const dispatchGeneration = getAuthGeneration();
const headers = requestHeaders(init);
const method = (init?.method ?? "GET").toUpperCase();
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
headers.delete("X-ThothII-CSRF");
const csrfToken = getAuthState()?.csrfToken;
if (csrfToken) headers.set("X-ThothII-CSRF", csrfToken);
}
const res = await fetch(url, {
...init,
credentials: "same-origin",
headers,
});
if (res.status === 401) clearAuthStateIfCurrent(dispatchGeneration);
if (!res.ok) {
const bodyText = await res.text().catch(() => "");
let payload: unknown;
try { payload = bodyText ? JSON.parse(bodyText) : undefined; } catch { payload = undefined; }
throw new ApiError(res.status, bodyText, payload);
const { text, truncated } = await readBoundedText(res);
const payload = parseSafeErrorPayload(text, truncated);
throw new ApiError(res.status, "", payload);
}
return res;
}
/** Refuse a credentialed cross-origin base before the browser can send a request. */
export function assertSameOriginRequestUrl(url: string): void {
if (typeof window === "undefined") {
if (/^https?:\/\//i.test(url)) throw new Error("The browser must use the same-origin /api route");
return;
}
const parsed = new URL(url, window.location.origin);
if (parsed.origin !== window.location.origin) {
throw new Error("The browser must use the same-origin /api route");
}
}
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
// Only declare a JSON content-type when we actually send a body. Body-less
// POSTs (resume, close) would otherwise make Fastify reject the empty body