feat(web): add login page with returnTo support and typed error UX

Inline errors for invalid creds vs. server-side failures. Validates
returnTo to block open-redirect vectors (//, http://, /login,
parent-traversal).
This commit is contained in:
2026-04-23 02:41:44 -04:00
parent d2d5d18e74
commit 78f69b8736
3 changed files with 206 additions and 2 deletions
+3 -2
View File
@@ -7,8 +7,9 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "svelte-kit sync && vitest run"
"sync": "svelte-kit sync",
"check": "svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { login } from '$lib/auth/store.svelte';
import type { ApiError } from '$lib/api/client';
let username = $state('');
let password = $state('');
let error = $state<string | null>(null);
let pending = $state(false);
function safeReturnTo(raw: string | null): string {
if (!raw) return '/';
// Starts with exactly one '/' (not '//', which is a protocol-relative URL)
// AND is not the login page itself.
if (!/^\/(?!\/)/.test(raw)) return '/';
if (raw === '/login' || raw.startsWith('/login?') || raw.startsWith('/login/')) return '/';
return raw;
}
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
if (pending) return;
error = null;
pending = true;
try {
await login(username, password);
const dest = safeReturnTo(page.url.searchParams.get('returnTo'));
goto(dest, { replaceState: true });
} catch (err) {
const apiErr = err as ApiError;
if (apiErr?.status === 401 && apiErr?.code === 'invalid_credentials') {
error = 'Invalid username or password.';
password = '';
} else {
error = 'Sign-in unavailable. Try again in a moment.';
}
} finally {
pending = false;
}
}
</script>
<main class="flex min-h-screen items-center justify-center bg-background text-text-primary">
<div class="w-full max-w-sm rounded-lg border border-border bg-surface p-6 shadow">
<h1 class="mb-6 text-center text-2xl font-semibold">Minstrel</h1>
<form class="space-y-4" onsubmit={handleSubmit}>
<label class="block">
<span class="mb-1 block text-sm text-text-secondary">Username</span>
<input
type="text"
autocomplete="username"
class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent"
bind:value={username}
required
/>
</label>
<label class="block">
<span class="mb-1 block text-sm text-text-secondary">Password</span>
<input
type="password"
autocomplete="current-password"
class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent"
bind:value={password}
required
/>
</label>
<button
type="submit"
class="w-full rounded bg-accent px-3 py-2 font-medium text-background disabled:opacity-60"
aria-busy={pending ? 'true' : undefined}
disabled={pending}
>
Sign in
</button>
{#if error}
<p role="alert" class="text-sm text-danger">{error}</p>
{/if}
</form>
</div>
</main>
+122
View File
@@ -0,0 +1,122 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
// vi.hoisted avoids the temporal-dead-zone hazard: mock factories are lazy
// and can run before top-level `let` is initialized, since imports are
// hoisted above variable declarations.
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/login') }));
vi.mock('$app/state', () => ({
page: {
get url() { return state.pageUrl; }
}
}));
vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
vi.mock('$lib/auth/store.svelte', () => ({
login: vi.fn(),
user: { value: null }
}));
import LoginPage from './+page.svelte';
import { login } from '$lib/auth/store.svelte';
import { goto } from '$app/navigation';
afterEach(() => {
vi.clearAllMocks();
state.pageUrl = new URL('http://localhost/login');
});
async function submit(username = 'alice', password = 'pw') {
const u = screen.getByLabelText(/username/i) as HTMLInputElement;
const p = screen.getByLabelText(/password/i) as HTMLInputElement;
await fireEvent.input(u, { target: { value: username } });
await fireEvent.input(p, { target: { value: password } });
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
}
describe('login page', () => {
test('renders username, password, and sign-in button', () => {
render(LoginPage);
expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
});
test('invalid credentials show inline error and clear password', async () => {
(login as ReturnType<typeof vi.fn>).mockRejectedValue({
code: 'invalid_credentials', message: 'bad creds', status: 401
});
render(LoginPage);
await submit();
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/invalid username or password/i);
});
expect((screen.getByLabelText(/password/i) as HTMLInputElement).value).toBe('');
expect((screen.getByLabelText(/username/i) as HTMLInputElement).value).toBe('alice');
});
test('server error shows generic message and preserves both fields', async () => {
(login as ReturnType<typeof vi.fn>).mockRejectedValue({
code: 'server_error', message: 'boom', status: 500
});
render(LoginPage);
await submit('alice', 'pw');
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/try again in a moment/i);
});
expect((screen.getByLabelText(/password/i) as HTMLInputElement).value).toBe('pw');
expect((screen.getByLabelText(/username/i) as HTMLInputElement).value).toBe('alice');
});
test('success navigates to returnTo when safe', async () => {
state.pageUrl = new URL('http://localhost/login?returnTo=/artists/abc');
(login as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
render(LoginPage);
await submit();
await waitFor(() => {
expect(goto).toHaveBeenCalledWith('/artists/abc', { replaceState: true });
});
});
test('success falls back to "/" when returnTo is missing', async () => {
(login as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
render(LoginPage);
await submit();
await waitFor(() => {
expect(goto).toHaveBeenCalledWith('/', { replaceState: true });
});
});
test.each([
['//evil.com', 'protocol-relative'],
['http://evil.com', 'absolute URL'],
['/login', 'self-redirect'],
['../admin', 'parent traversal']
])('rejects unsafe returnTo %s (%s) and falls back to "/"', async (value) => {
state.pageUrl = new URL(`http://localhost/login?returnTo=${encodeURIComponent(value)}`);
(login as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
render(LoginPage);
await submit();
await waitFor(() => {
expect(goto).toHaveBeenCalledWith('/', { replaceState: true });
});
});
test('submit button disables and marks aria-busy while pending', async () => {
let release!: () => void;
(login as ReturnType<typeof vi.fn>).mockReturnValue(
new Promise<void>((resolve) => { release = () => resolve(); })
);
render(LoginPage);
await submit();
const btn = screen.getByRole('button', { name: /sign in/i });
expect(btn).toBeDisabled();
expect(btn).toHaveAttribute('aria-busy', 'true');
release();
await waitFor(() => expect(btn).not.toBeDisabled());
});
});