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;
|
_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> {
|
export async function logout(opts: { silent?: boolean } = {}): Promise<void> {
|
||||||
const userId = _user?.id;
|
const userId = _user?.id;
|
||||||
if (!opts.silent) {
|
if (!opts.silent) {
|
||||||
|
|||||||
@@ -80,5 +80,10 @@
|
|||||||
<p role="alert" class="text-sm text-danger">{error}</p>
|
<p role="alert" class="text-sm text-danger">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</form>
|
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ describe('login page', () => {
|
|||||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
|
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 () => {
|
test('invalid credentials show inline error and clear password', async () => {
|
||||||
(login as ReturnType<typeof vi.fn>).mockRejectedValue({
|
(login as ReturnType<typeof vi.fn>).mockRejectedValue({
|
||||||
code: 'invalid_credentials', message: 'bad creds', status: 401
|
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