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:
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 3–32 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>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user