feat(web/m7-user-mgmt): /register page + login link

Public /register form: username, optional display name, password +
confirm, optional invite token. Submits to POST /api/auth/register
via the new register() helper in auth/store.svelte.ts. On success
the server sets the session cookie and the frontend redirects to /;
on error the page shows a code-specific message (invite_required,
invite_invalid, username_taken, etc.).

Login page gets a "Don't have an account? Register" link below the
form for symmetry.

Tests cover form rendering, password-mismatch client-side rejection,
the happy-path submit + redirect, and the invite-invalid error
mapping.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 12:04:06 -04:00
parent bd362ab384
commit 6b23532a61
5 changed files with 293 additions and 0 deletions
+17
View File
@@ -24,6 +24,23 @@ export async function login(username: string, password: string): Promise<void> {
_user = res.user;
}
export async function register(opts: {
username: string;
password: string;
inviteToken?: string;
displayName?: string;
}): Promise<void> {
const body: Record<string, string> = {
username: opts.username,
password: opts.password,
};
if (opts.inviteToken) body.invite_token = opts.inviteToken;
if (opts.displayName) body.display_name = opts.displayName;
const res = await api.post<LoginResponse>('/api/auth/register', body);
_user = res.user;
await bootstrap();
}
export async function logout(opts: { silent?: boolean } = {}): Promise<void> {
const userId = _user?.id;
if (!opts.silent) {
+5
View File
@@ -80,5 +80,10 @@
<p role="alert" class="text-sm text-danger">{error}</p>
{/if}
</form>
<p class="mt-4 text-center text-sm text-text-secondary">
Don't have an account?
<a href="/register" class="text-accent hover:underline">Register</a>
</p>
</div>
</main>
+6
View File
@@ -46,6 +46,12 @@ describe('login page', () => {
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
});
test('renders a link to the register page', () => {
render(LoginPage);
const link = screen.getByRole('link', { name: /register/i });
expect(link).toHaveAttribute('href', '/register');
});
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
+140
View File
@@ -0,0 +1,140 @@
<script lang="ts">
import { pageTitle } from '$lib/branding';
import { goto } from '$app/navigation';
import { register } from '$lib/auth/store.svelte';
let username = $state('');
let password = $state('');
let confirmPassword = $state('');
let inviteToken = $state('');
let displayName = $state('');
let submitting = $state(false);
let error = $state<string | null>(null);
function errorMessageFor(code: string): string {
switch (code) {
case 'username_invalid':
return 'Username must be 332 characters: letters, numbers, underscores, hyphens.';
case 'password_too_short':
return 'Password must be at least 8 characters.';
case 'invite_required':
return 'This server requires an invite token to register. Ask an admin for one.';
case 'invite_invalid':
return 'That invite token is invalid, expired, or already used.';
case 'username_taken':
return 'That username is already taken.';
default:
return 'Registration failed. Please try again.';
}
}
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
if (submitting) return;
error = null;
if (password !== confirmPassword) {
error = 'Passwords do not match.';
return;
}
submitting = true;
try {
await register({
username,
password,
inviteToken: inviteToken || undefined,
displayName: displayName || undefined,
});
await goto('/', { replaceState: true });
} catch (err: unknown) {
const code = (err as { code?: string })?.code ?? 'unknown';
error = errorMessageFor(code);
} finally {
submitting = false;
}
}
</script>
<svelte:head><title>{pageTitle('Register')}</title></svelte:head>
<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">Create account</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"
required
minlength="3"
maxlength="32"
class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent"
bind:value={username}
/>
</label>
<label class="block">
<span class="mb-1 block text-sm text-text-secondary">
Display name <span class="text-text-secondary opacity-60">(optional)</span>
</span>
<input
type="text"
autocomplete="name"
maxlength="64"
class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent"
bind:value={displayName}
/>
</label>
<label class="block">
<span class="mb-1 block text-sm text-text-secondary">Password</span>
<input
type="password"
autocomplete="new-password"
required
minlength="8"
class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent"
bind:value={password}
/>
</label>
<label class="block">
<span class="mb-1 block text-sm text-text-secondary">Confirm password</span>
<input
type="password"
autocomplete="new-password"
required
minlength="8"
class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent"
bind:value={confirmPassword}
/>
</label>
<label class="block">
<span class="mb-1 block text-sm text-text-secondary">
Invite token <span class="text-text-secondary opacity-60">(if required by your server)</span>
</span>
<input
type="text"
autocomplete="off"
class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent"
bind:value={inviteToken}
/>
</label>
<button
type="submit"
class="w-full rounded bg-accent px-3 py-2 font-medium text-background disabled:opacity-60"
aria-busy={submitting ? 'true' : undefined}
disabled={submitting}
>
{submitting ? 'Creating account…' : 'Create account'}
</button>
{#if error}
<p role="alert" class="text-sm text-danger">{error}</p>
{/if}
</form>
<p class="mt-4 text-center text-sm text-text-secondary">
Already have an account?
<a href="/login" class="text-accent hover:underline">Sign in</a>
</p>
</div>
</main>
+125
View File
@@ -0,0 +1,125 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
vi.mock('$lib/branding', () => ({
pageTitle: (s: string) => `${s} · Minstrel`
}));
vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
vi.mock('$lib/auth/store.svelte', () => ({
register: vi.fn(),
user: { value: null }
}));
import RegisterPage from './+page.svelte';
import { register } from '$lib/auth/store.svelte';
import { goto } from '$app/navigation';
afterEach(() => {
vi.clearAllMocks();
});
describe('/register page', () => {
test('renders username, password, confirm password, and invite token fields', () => {
render(RegisterPage);
expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument();
expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument();
expect(screen.getByLabelText(/invite token/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /create account/i })).toBeInTheDocument();
});
test('rejects mismatched passwords without calling register', async () => {
render(RegisterPage);
await fireEvent.input(screen.getByLabelText(/username/i), { target: { value: 'newuser' } });
await fireEvent.input(screen.getByLabelText(/^password$/i), { target: { value: 'abcd1234' } });
await fireEvent.input(screen.getByLabelText(/confirm password/i), { target: { value: 'differentpw' } });
await fireEvent.click(screen.getByRole('button', { name: /create account/i }));
expect(register).not.toHaveBeenCalled();
expect(screen.getByRole('alert').textContent).toMatch(/do not match/i);
});
test('calls register with form values and redirects on success', async () => {
(register as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
render(RegisterPage);
await fireEvent.input(screen.getByLabelText(/username/i), { target: { value: 'newuser' } });
await fireEvent.input(screen.getByLabelText(/^password$/i), { target: { value: 'abcd1234' } });
await fireEvent.input(screen.getByLabelText(/confirm password/i), { target: { value: 'abcd1234' } });
await fireEvent.input(screen.getByLabelText(/invite token/i), { target: { value: 'abc123' } });
await fireEvent.click(screen.getByRole('button', { name: /create account/i }));
await waitFor(() => {
expect(register).toHaveBeenCalledWith(expect.objectContaining({
username: 'newuser',
password: 'abcd1234',
inviteToken: 'abc123',
}));
});
await waitFor(() => {
expect(goto).toHaveBeenCalledWith('/', { replaceState: true });
});
});
test('shows error message for invite_invalid code', async () => {
(register as ReturnType<typeof vi.fn>).mockRejectedValue({ code: 'invite_invalid' });
render(RegisterPage);
await fireEvent.input(screen.getByLabelText(/username/i), { target: { value: 'newuser' } });
await fireEvent.input(screen.getByLabelText(/^password$/i), { target: { value: 'abcd1234' } });
await fireEvent.input(screen.getByLabelText(/confirm password/i), { target: { value: 'abcd1234' } });
await fireEvent.click(screen.getByRole('button', { name: /create account/i }));
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toMatch(/invite token is invalid/i);
});
});
test('shows error message for username_taken code', async () => {
(register as ReturnType<typeof vi.fn>).mockRejectedValue({ code: 'username_taken' });
render(RegisterPage);
await fireEvent.input(screen.getByLabelText(/username/i), { target: { value: 'existinguser' } });
await fireEvent.input(screen.getByLabelText(/^password$/i), { target: { value: 'abcd1234' } });
await fireEvent.input(screen.getByLabelText(/confirm password/i), { target: { value: 'abcd1234' } });
await fireEvent.click(screen.getByRole('button', { name: /create account/i }));
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toMatch(/username is already taken/i);
});
});
test('shows error message for invite_required code', async () => {
(register as ReturnType<typeof vi.fn>).mockRejectedValue({ code: 'invite_required' });
render(RegisterPage);
await fireEvent.input(screen.getByLabelText(/username/i), { target: { value: 'newuser' } });
await fireEvent.input(screen.getByLabelText(/^password$/i), { target: { value: 'abcd1234' } });
await fireEvent.input(screen.getByLabelText(/confirm password/i), { target: { value: 'abcd1234' } });
await fireEvent.click(screen.getByRole('button', { name: /create account/i }));
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toMatch(/invite token/i);
});
});
test('submit button disables and marks aria-busy while pending', async () => {
let release!: () => void;
(register as ReturnType<typeof vi.fn>).mockReturnValue(
new Promise<void>((resolve) => { release = () => resolve(); })
);
render(RegisterPage);
await fireEvent.input(screen.getByLabelText(/username/i), { target: { value: 'newuser' } });
await fireEvent.input(screen.getByLabelText(/^password$/i), { target: { value: 'abcd1234' } });
await fireEvent.input(screen.getByLabelText(/confirm password/i), { target: { value: 'abcd1234' } });
await fireEvent.click(screen.getByRole('button', { name: /create account/i }));
const btn = screen.getByRole('button', { name: /creating account/i });
expect(btn).toBeDisabled();
expect(btn).toHaveAttribute('aria-busy', 'true');
release();
await waitFor(() => expect(screen.getByRole('button', { name: /create account/i })).not.toBeDisabled());
});
test('login page has link to register', () => {
// The login page symmetrically has a "Register" link - tested here via the
// register page having a "Sign in" link back to /login.
render(RegisterPage);
const link = screen.getByRole('link', { name: /sign in/i });
expect(link).toHaveAttribute('href', '/login');
});
});