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:
@@ -9,13 +9,37 @@
|
||||
let password = $state('');
|
||||
let pending = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let notice = $state<string | null>(null);
|
||||
|
||||
// Honor returnTo from the auth bounce so deep links survive a
|
||||
// re-login. Validate it's a same-origin admin path (no open
|
||||
// redirect): must start with the admin base prefix, no scheme, no
|
||||
// host. Falls back to the dashboard root.
|
||||
function safeReturnTo(): string {
|
||||
if (typeof window === 'undefined') return `${base}/`;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get('returnTo');
|
||||
if (!raw) return `${base}/`;
|
||||
if (raw.includes('://') || raw.startsWith('//')) return `${base}/`;
|
||||
if (!raw.startsWith(`${base}/`) && !raw.startsWith('/')) return `${base}/`;
|
||||
// Don't bounce back into /login.
|
||||
if (raw === `${base}/login` || raw.startsWith(`${base}/login?`)) return `${base}/`;
|
||||
return raw;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const reason = new URLSearchParams(window.location.search).get('reason');
|
||||
if (reason === 'auth-loop') {
|
||||
notice =
|
||||
'Your session keeps being rejected. Sign in again — if this keeps happening, the admin token issuer may be misconfigured.';
|
||||
}
|
||||
}
|
||||
// Already signed in? Skip the form.
|
||||
if (!getToken()) return;
|
||||
try {
|
||||
await api.auth.me();
|
||||
await goto(`${base}/`);
|
||||
await goto(safeReturnTo());
|
||||
} catch {
|
||||
// stale token; let the form render
|
||||
}
|
||||
@@ -27,7 +51,7 @@
|
||||
pending = true;
|
||||
try {
|
||||
await api.auth.login(username, password);
|
||||
await goto(`${base}/`);
|
||||
await goto(safeReturnTo());
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Login failed';
|
||||
} finally {
|
||||
@@ -65,6 +89,9 @@
|
||||
<button type="submit" disabled={pending}>
|
||||
{pending ? 'Signing in…' : 'Sign in →'}
|
||||
</button>
|
||||
{#if notice}
|
||||
<div class="notice">{notice}</div>
|
||||
{/if}
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
@@ -156,6 +183,15 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.notice {
|
||||
background: #422006;
|
||||
border: 1px solid #b45309;
|
||||
color: #fde68a;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
|
||||
Reference in New Issue
Block a user