fix(stage-2): audit dashboard TS alignment + login UX
Closes 4 audit findings: - Route TS interface now carries dispatch_mode (sync|async). Backend's shared::route::Route has had this since 0012_routes_dispatch_mode but the TS client silently always created sync routes and the list display dropped the field. Add a Dispatch select to the new-route form and an "ASYNC" badge in the route list. - api.files.downloadUrl pointed to a never-registered backend endpoint. The dashboard's live Download button was hitting 405. Add the GET handler (AppFilesRead + FilesRepo::head + FilesRepo::get, Content- Disposition: inline) at the same path that delete already used. - F-T-003: adminRequest's 401 handler called goto(login) without a recursion cap. If the login endpoint itself returned 401, the wrapper looped until browser nav limits. Track consecutive 401s within a 10s window and hard-reload to /login?reason=auth-loop after the third, showing the user an explanatory banner. Any 2xx resets the counter. - Login flow now honors a ?returnTo= query parameter. adminRequest and the root layout both append the current location when redirecting to /login; the login page validates the value is a same-origin admin path (no open-redirect) and goto's there after successful sign-in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -100,6 +100,7 @@ export interface PatchAppInput {
|
||||
|
||||
export type HostKind = 'any' | 'strict' | 'wildcard';
|
||||
export type PathKind = 'exact' | 'prefix' | 'param';
|
||||
export type DispatchMode = 'sync' | 'async';
|
||||
|
||||
export interface Route {
|
||||
id: string;
|
||||
@@ -111,6 +112,7 @@ export interface Route {
|
||||
path_kind: PathKind;
|
||||
path: string;
|
||||
method: string | null;
|
||||
dispatch_mode: DispatchMode;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -121,6 +123,7 @@ export interface RouteInput {
|
||||
path_kind: PathKind;
|
||||
path: string;
|
||||
method?: string | null;
|
||||
dispatch_mode?: DispatchMode;
|
||||
}
|
||||
|
||||
export interface CheckRouteResponse {
|
||||
@@ -461,6 +464,27 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// F-T-003: if the server keeps returning 401 (e.g. the bounce to
|
||||
// /login itself triggered another 401-emitting request), the previous
|
||||
// implementation would just keep calling `goto(login)` — a tight loop.
|
||||
// Track consecutive 401s; after the threshold within the window, hard-
|
||||
// reload to a static auth-loop page so the user sees something other
|
||||
// than a spinning dashboard. Any 2xx response resets the count.
|
||||
let consecutive401Count = 0;
|
||||
let first401At = 0;
|
||||
const AUTH_LOOP_THRESHOLD = 3;
|
||||
const AUTH_LOOP_WINDOW_MS = 10_000;
|
||||
|
||||
function loginUrlWithReturnTo(): string {
|
||||
if (!browser) return `${base}/login`;
|
||||
const here = `${window.location.pathname}${window.location.search}`;
|
||||
// Don't loop returnTo back to /login itself.
|
||||
if (here === `${base}/login` || here.startsWith(`${base}/login?`)) {
|
||||
return `${base}/login`;
|
||||
}
|
||||
return `${base}/login?returnTo=${encodeURIComponent(here)}`;
|
||||
}
|
||||
|
||||
async function adminRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/json',
|
||||
@@ -478,9 +502,27 @@ async function adminRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
// and bounce to login — unless we're already on it, in which
|
||||
// case throw and let the login form render the error.
|
||||
clearSession();
|
||||
if (browser && !window.location.pathname.endsWith('/login')) {
|
||||
void goto(`${base}/login`);
|
||||
const now = Date.now();
|
||||
if (now - first401At > AUTH_LOOP_WINDOW_MS) {
|
||||
first401At = now;
|
||||
consecutive401Count = 1;
|
||||
} else {
|
||||
consecutive401Count += 1;
|
||||
}
|
||||
if (browser && !window.location.pathname.endsWith('/login')) {
|
||||
if (consecutive401Count >= AUTH_LOOP_THRESHOLD) {
|
||||
// Hard-reload via location.assign — Svelte's goto keeps
|
||||
// the SPA state which is what's keeping the loop alive.
|
||||
window.location.assign(`${base}/login?reason=auth-loop`);
|
||||
} else {
|
||||
void goto(loginUrlWithReturnTo());
|
||||
}
|
||||
}
|
||||
} else if (res.ok) {
|
||||
// Any successful response resets the loop counter — the user has
|
||||
// a valid session again.
|
||||
consecutive401Count = 0;
|
||||
first401At = 0;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
@@ -908,8 +950,9 @@ export const api = {
|
||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/files/${encodeURIComponent(collection)}/${fileId}`,
|
||||
{ method: 'DELETE' }
|
||||
),
|
||||
/// F-U-009: build the admin download URL. GET returns the file
|
||||
/// bytes inline; the browser handles content-disposition.
|
||||
// Build the admin download URL. GET returns the file bytes
|
||||
// inline with Content-Disposition set from the stored filename;
|
||||
// the browser handles the download.
|
||||
downloadUrl: (idOrSlug: string, collection: string, fileId: string): string =>
|
||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/files/${encodeURIComponent(collection)}/${fileId}`
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user