From 6b23532a61358fda49d380c8aa378b804a436194 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 7 May 2026 12:04:06 -0400 Subject: [PATCH] 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 --- web/src/lib/auth/store.svelte.ts | 17 +++ web/src/routes/login/+page.svelte | 5 + web/src/routes/login/login.test.ts | 6 + web/src/routes/register/+page.svelte | 140 +++++++++++++++++++++++ web/src/routes/register/register.test.ts | 125 ++++++++++++++++++++ 5 files changed, 293 insertions(+) create mode 100644 web/src/routes/register/+page.svelte create mode 100644 web/src/routes/register/register.test.ts diff --git a/web/src/lib/auth/store.svelte.ts b/web/src/lib/auth/store.svelte.ts index 662bf0bf..bcf91921 100644 --- a/web/src/lib/auth/store.svelte.ts +++ b/web/src/lib/auth/store.svelte.ts @@ -24,6 +24,23 @@ export async function login(username: string, password: string): Promise { _user = res.user; } +export async function register(opts: { + username: string; + password: string; + inviteToken?: string; + displayName?: string; +}): Promise { + const body: Record = { + 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('/api/auth/register', body); + _user = res.user; + await bootstrap(); +} + export async function logout(opts: { silent?: boolean } = {}): Promise { const userId = _user?.id; if (!opts.silent) { diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/login/+page.svelte index ccbc164b..3e18841a 100644 --- a/web/src/routes/login/+page.svelte +++ b/web/src/routes/login/+page.svelte @@ -80,5 +80,10 @@ {/if} + +

+ Don't have an account? + Register +

diff --git a/web/src/routes/login/login.test.ts b/web/src/routes/login/login.test.ts index 38ec3987..315fbd73 100644 --- a/web/src/routes/login/login.test.ts +++ b/web/src/routes/login/login.test.ts @@ -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).mockRejectedValue({ code: 'invalid_credentials', message: 'bad creds', status: 401 diff --git a/web/src/routes/register/+page.svelte b/web/src/routes/register/+page.svelte new file mode 100644 index 00000000..a753a424 --- /dev/null +++ b/web/src/routes/register/+page.svelte @@ -0,0 +1,140 @@ + + +{pageTitle('Register')} + +
+
+

Create account

+
+ + + + + + + + + {#if error} + + {/if} +
+ +

+ Already have an account? + Sign in +

+
+
diff --git a/web/src/routes/register/register.test.ts b/web/src/routes/register/register.test.ts new file mode 100644 index 00000000..644703b9 --- /dev/null +++ b/web/src/routes/register/register.test.ts @@ -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).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).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).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).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).mockReturnValue( + new Promise((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'); + }); +});