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",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"sync": "svelte-kit sync",
|
||||||
"test": "svelte-kit sync && vitest run"
|
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
"@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