Files
PiCloud/dashboard/src/routes/login/+page.svelte
MechaCat02 f466b2a15e 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>
2026-06-10 21:09:53 +02:00

209 lines
4.4 KiB
Svelte

<script lang="ts">
import { base } from '$app/paths';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { api, ApiError } from '$lib/api';
import { getToken } from '$lib/auth';
let username = $state('');
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(safeReturnTo());
} catch {
// stale token; let the form render
}
});
async function submit(event: SubmitEvent) {
event.preventDefault();
error = null;
pending = true;
try {
await api.auth.login(username, password);
await goto(safeReturnTo());
} catch (e) {
error = e instanceof ApiError ? e.message : 'Login failed';
} finally {
pending = false;
}
}
</script>
<div class="login-shell">
<form class="card" onsubmit={submit}>
<h1>PiCloud</h1>
<p class="sub">Admin sign-in</p>
<label>
<span>Username</span>
<input
name="username"
type="text"
autocomplete="username"
autocapitalize="off"
spellcheck="false"
bind:value={username}
required
/>
</label>
<label>
<span>Password</span>
<input
name="password"
type="password"
autocomplete="current-password"
bind:value={password}
required
/>
</label>
<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}
<p class="hint">
Lost access? Run <code>picloud admin reset-password &lt;username&gt;</code> on the host.
</p>
</form>
</div>
<style>
.login-shell {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
.card {
display: flex;
flex-direction: column;
gap: 1rem;
background: #0b1220;
border: 1px solid #1e293b;
border-radius: 0.5rem;
padding: 2rem;
min-width: 22rem;
max-width: 26rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
color: #38bdf8;
text-align: center;
}
.sub {
margin: 0 0 0.5rem 0;
text-align: center;
color: #94a3b8;
font-size: 0.9rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.4rem;
font-size: 0.85rem;
color: #cbd5e1;
}
input {
background: #0f172a;
color: #e2e8f0;
border: 1px solid #1e293b;
border-radius: 0.375rem;
padding: 0.6rem 0.75rem;
font-size: 0.95rem;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: #38bdf8;
}
button {
background: #38bdf8;
color: #0b1220;
border: none;
padding: 0.65rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
font-size: 0.95rem;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
background: #450a0a;
border: 1px solid #b91c1c;
color: #fecaca;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
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;
color: #64748b;
text-align: center;
}
code {
background: #1e293b;
padding: 0.1rem 0.35rem;
border-radius: 0.25rem;
color: #cbd5e1;
}
</style>