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:
+3
-2
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user