diff --git a/prisma/migrations/20261016000000_invite/migration.sql b/prisma/migrations/20261016000000_invite/migration.sql new file mode 100644 index 0000000..c58e348 --- /dev/null +++ b/prisma/migrations/20261016000000_invite/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "Invite" ( + "id" TEXT NOT NULL PRIMARY KEY, + "tokenHash" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" DATETIME NOT NULL, + "usedAt" DATETIME, + "createdByUserId" TEXT NOT NULL, + CONSTRAINT "Invite_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "Invite_tokenHash_key" ON "Invite"("tokenHash"); + +-- CreateIndex +CREATE INDEX "Invite_createdByUserId_idx" ON "Invite"("createdByUserId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2857135..2895529 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -105,6 +105,7 @@ model User { mustChangePassword Boolean @default(false) sessions Session[] accounts Account[] + invites Invite[] } model Session { @@ -151,3 +152,16 @@ model Verification { @@index([identifier]) } + +/// An invite link for one new account. Only the SHA-256 of the token is stored; the link is shown once. +model Invite { + id String @id + tokenHash String @unique + createdAt DateTime @default(now()) + expiresAt DateTime + usedAt DateTime? + createdByUserId String + createdBy User @relation(fields: [createdByUserId], references: [id], onDelete: Cascade) + + @@index([createdByUserId]) +} diff --git a/src/app.d.ts b/src/app.d.ts index 1395533..13213b1 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,6 +1,7 @@ import type { Setup } from '$lib/server/app'; import type { Identity } from '$lib/server/identity'; import type { Auth } from '$lib/server/identity/auth'; +import type { Invites } from '$lib/server/identity/invites'; import type { Instance } from '$lib/server/instance'; import type { Learner } from '$lib/server/learning/core'; @@ -19,6 +20,8 @@ declare global { setup: Setup; /** Better Auth; set only in Multi-user Mode, where the auth route mounts its handler. */ auth?: Auth; + /** Invite links; set exactly when `auth` is (Multi-user Mode), so Local Mode never has them. */ + invites?: Invites; /** Who this request is, from Identity. Unset only on /setup before the Instance is set up. */ identity: Identity; /** The Learning core acting for this request's Learner. Unset exactly when `identity` is. */ diff --git a/src/lib/server/app.test.ts b/src/lib/server/app.test.ts index 781225a..1c9de95 100644 --- a/src/lib/server/app.test.ts +++ b/src/lib/server/app.test.ts @@ -111,6 +111,9 @@ describe('Multi-user', () => { request: { headers: new Headers({ cookie }) } }); expect(who).toMatchObject({ displayName: 'Ada', role: 'admin', learnerId: 'local' }); + // Invite links carry the validated ORIGIN, the one Better Auth uses. + const made = await app.multiUser?.invites.create({ who: who as never, now: new Date() }); + expect(made?.link).toBe(`http://localhost:4174/invite/${made?.token}`); expect(await db.learner.findMany()).toEqual([expect.objectContaining({ id: 'local' })]); }, 30_000); diff --git a/src/lib/server/app.ts b/src/lib/server/app.ts index e5107df..2b72095 100644 --- a/src/lib/server/app.ts +++ b/src/lib/server/app.ts @@ -2,6 +2,7 @@ import { type Catalogue, loadCatalogue } from './content/catalogue.ts'; import { createDb, type Db } from './db.ts'; import { type Auth, createAuth, isPlaceholderSecret, MIN_SECRET_LENGTH } from './identity/auth.ts'; import { createLocalIdentity, type IdentityResolver } from './identity/index.ts'; +import { createInvites, type Invites } from './identity/invites.ts'; import { createMultiUserIdentity } from './identity/multi-user.ts'; import { createRegistration } from './identity/registration.ts'; import { @@ -41,7 +42,7 @@ export interface App { /** The Instance's mode, read by the hook on every request. One per server: it caches the mode. */ instance: Instance; /** Better Auth and the Multi-user adapter; null when the secret or the origin is not set. */ - multiUser: { auth: Auth; identity: IdentityResolver } | null; + multiUser: { auth: Auth; identity: IdentityResolver; invites: Invites } | null; setup: Setup; } @@ -79,7 +80,7 @@ export async function createApp(env: { const db = createDb(env.DATABASE_URL); const instance = await createInstance(db); const auth = buildAuth(env, db, instance); - const multiUser = auth.kind === 'built' ? chooseMultiUser(auth.auth) : null; + const multiUser = auth.kind === 'built' ? chooseMultiUser(auth, db) : null; if (instance.mode() === 'multi') { // The test cookie is read only by the Local adapter, which a multi-user Instance never consults; // refuse the combination anyway. The browser tests' Multi-user server boots empty and becomes multi. @@ -146,7 +147,9 @@ function refuseAuthEnvOverrides(): void { } } -type BuiltAuth = { kind: 'built'; auth: Auth } | { kind: 'missing'; message: string }; +type BuiltAuth = + | { kind: 'built'; auth: Auth; origin: string } + | { kind: 'missing'; message: string }; /** * Better Auth, when the secret is set and long enough and ORIGIN is a real http(s) URL. Never with an @@ -190,6 +193,7 @@ function buildAuth( } return { kind: 'built', + origin, auth: createAuth({ db, origin, secret, admitSignUp: createRegistration({ instance }) }) }; } @@ -203,7 +207,12 @@ function originOf(value: string | undefined): string | null { } } -const chooseMultiUser = (auth: Auth) => ({ auth, identity: createMultiUserIdentity(auth) }); +const chooseMultiUser = (built: Extract, db: Db) => ({ + auth: built.auth, + identity: createMultiUserIdentity(built.auth), + // Invite links name the same ORIGIN the cookies and the origin check use. + invites: createInvites({ db, origin: built.origin }) +}); /** Multi-user is open to every Instance that has what Better Auth needs; otherwise Setup says what to add. */ function choice(auth: BuiltAuth): MultiUserChoice { diff --git a/src/lib/server/handle.test.ts b/src/lib/server/handle.test.ts index 7fc1482..9ffeb38 100644 --- a/src/lib/server/handle.test.ts +++ b/src/lib/server/handle.test.ts @@ -4,6 +4,7 @@ import type { App } from './app.ts'; import { createHandle } from './handle.ts'; import { createAuth } from './identity/auth.ts'; import { createLocalIdentity } from './identity/index.ts'; +import { createInvites } from './identity/invites.ts'; import { createMultiUserIdentity } from './identity/multi-user.ts'; import { createRegistration } from './identity/registration.ts'; import { createFirstAdmin } from './identity/setup.ts'; @@ -192,7 +193,11 @@ describe('Multi-user Mode', () => { .join('; '); const identity = await createLocalIdentity(db, options); const core = { forLearner: (learnerId: string) => ({ learnerId }) } as unknown as App['core']; - const multiUser = { auth, identity: createMultiUserIdentity(auth) }; + const multiUser = { + auth, + identity: createMultiUserIdentity(auth), + invites: createInvites({ db, origin: 'http://localhost:3000' }) + }; const setupPort = { multiUser: { kind: 'open' }, createFirstAdmin: vi.fn() } as App['setup']; return { db, @@ -204,6 +209,18 @@ describe('Multi-user Mode', () => { }; } + it('puts the Invites on locals in Multi-user Mode, and never in Local Mode', async () => { + const { handle, asking } = await multiSetup(); + const { event, locals, resolve } = asking('/', '/'); + await handle({ event, resolve }); + expect(locals.invites).toBeDefined(); + + const local = await setup({ local: true }); + const asked = request('/', '/'); + await local.handle({ event: asked.event, resolve: asked.resolve }); + expect(asked.locals.invites).toBeUndefined(); + }); + it('resolves the session cookie to its Account and binds the Learning core to its Learner', async () => { const { handle, asking } = await multiSetup(); const { event, locals, resolve } = asking('/', '/'); diff --git a/src/lib/server/handle.ts b/src/lib/server/handle.ts index dfe5cfb..ccb3aed 100644 --- a/src/lib/server/handle.ts +++ b/src/lib/server/handle.ts @@ -30,6 +30,7 @@ export function createHandle( // Cannot happen at start (createApp refuses it); fail closed rather than serve nobody. if (!app.multiUser) return refuseFraming(refusal(503)); event.locals.auth = app.multiUser.auth; + event.locals.invites = app.multiUser.invites; who = await app.multiUser.identity.resolve(event); } // An active session was extended: its new cookie goes out with whatever this request answers (a @@ -78,12 +79,14 @@ export function createHandle( } // No theme cookie means follow the device; a choice goes on in the server render, so no flash. // Read when the page renders, not before: an action that just set the cookie is seen by its own response. - return finish( - await resolve(event, { - transformPageChunk: ({ html }) => - withTheme(html, parseTheme(event.cookies.get(THEME_COOKIE))) - }) - ); + const response = await resolve(event, { + transformPageChunk: ({ html }) => withTheme(html, parseTheme(event.cookies.get(THEME_COOKIE))) + }); + // Users shows everyone's names and emails and the one-time Invite link: never cached, whatever + // answers (the page, a data request, an action result, an error). Set here, once, because a + // route that sets it in both its action and its load makes SvelteKit throw on a no-JS form post. + if (event.route.id === '/users') response.headers.set('Cache-Control', 'no-store'); + return finish(response); }; } diff --git a/src/lib/server/identity/invites.test.ts b/src/lib/server/identity/invites.test.ts new file mode 100644 index 0000000..d7f5a59 --- /dev/null +++ b/src/lib/server/identity/invites.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; +import { createTestDb } from '../test-db'; +import { NotAdminError } from './accounts'; +import { createInvites, hashToken, INVITE_LIFETIME_MS } from './invites'; + +const origin = 'https://learn.example.com'; +const now = new Date('2026-10-16T12:00:00.000Z'); +const admin = { learnerId: 'L-admin', displayName: 'Ada', role: 'admin' as const }; +const learner = { learnerId: 'L-lin', displayName: 'Lin', role: 'learner' as const }; + +async function setup() { + const db = await createTestDb(); + await db.learner.create({ data: { id: admin.learnerId } }); + await db.user.create({ + data: { + id: 'u-admin', + name: 'Ada', + email: 'ada@example.com', + role: 'admin', + learnerId: 'L-admin' + } + }); + return { db, invites: createInvites({ db, origin }) }; +} + +describe('create', () => { + it('returns a 43-character base64url token and a link on the origin', async () => { + const { invites } = await setup(); + const made = await invites.create({ who: admin, now }); + expect(made.token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(made.link).toBe(`${origin}/invite/${made.token}`); + }); + + it('stores only the SHA-256 of the token, valid for 7 days', async () => { + const { db, invites } = await setup(); + const made = await invites.create({ who: admin, now }); + const rows = await db.invite.findMany(); + expect(rows).toHaveLength(1); + expect(rows[0].tokenHash).toBe(hashToken(made.token)); + expect(rows[0].tokenHash).not.toContain(made.token); + expect(JSON.stringify(rows)).not.toContain(made.token); + expect(rows[0].createdByUserId).toBe('u-admin'); + expect(rows[0].usedAt).toBeNull(); + expect(rows[0].expiresAt.getTime() - now.getTime()).toBe(INVITE_LIFETIME_MS); + expect(made.expiresAt).toBe(rows[0].expiresAt.toISOString()); + }); + + it('makes a different token each time', async () => { + const { invites } = await setup(); + const a = await invites.create({ who: admin, now }); + const b = await invites.create({ who: admin, now }); + expect(a.token).not.toBe(b.token); + }); + + it('refuses a learner and writes nothing', async () => { + const { db, invites } = await setup(); + await expect(invites.create({ who: learner, now })).rejects.toBeInstanceOf(NotAdminError); + expect(await db.invite.count()).toBe(0); + }); + + it('is removed with the admin who made it', async () => { + const { db, invites } = await setup(); + await invites.create({ who: admin, now }); + await db.user.delete({ where: { id: 'u-admin' } }); + expect(await db.invite.count()).toBe(0); + }); +}); + +describe('listPending', () => { + it('lists unused, unexpired Invites without the token or its hash', async () => { + const { db, invites } = await setup(); + const made = await invites.create({ who: admin, now }); + const rows = await invites.listPending({ who: admin, now }); + expect(rows).toEqual([ + { id: made.id, createdAt: now.toISOString(), expiresAt: made.expiresAt } + ]); + const text = JSON.stringify(rows); + expect(text).not.toContain(made.token); + const stored = await db.invite.findFirstOrThrow(); + expect(text).not.toContain(stored.tokenHash); + }); + + it('leaves out used and expired Invites', async () => { + const { db, invites } = await setup(); + const used = await invites.create({ who: admin, now }); + await db.invite.update({ where: { id: used.id }, data: { usedAt: now } }); + await invites.create({ who: admin, now }); + const later = new Date(now.getTime() + INVITE_LIFETIME_MS); + expect(await invites.listPending({ who: admin, now })).toHaveLength(1); + expect(await invites.listPending({ who: admin, now: later })).toHaveLength(0); + }); + + it('refuses a learner', async () => { + const { invites } = await setup(); + await expect(invites.listPending({ who: learner, now })).rejects.toBeInstanceOf(NotAdminError); + }); +}); + +describe('cancel', () => { + it('deletes a pending Invite, and its link no longer works', async () => { + const { invites } = await setup(); + const made = await invites.create({ who: admin, now }); + expect(await invites.cancel({ who: admin, id: made.id })).toBe(true); + expect(await invites.lookup(made.token, now)).toBeNull(); + expect(await invites.listPending({ who: admin, now })).toEqual([]); + }); + + it('is safe for an unknown, cancelled or used Invite', async () => { + const { db, invites } = await setup(); + const made = await invites.create({ who: admin, now }); + await invites.cancel({ who: admin, id: made.id }); + expect(await invites.cancel({ who: admin, id: made.id })).toBe(false); + expect(await invites.cancel({ who: admin, id: 'nope' })).toBe(false); + const used = await invites.create({ who: admin, now }); + await db.invite.update({ where: { id: used.id }, data: { usedAt: now } }); + expect(await invites.cancel({ who: admin, id: used.id })).toBe(false); + expect(await db.invite.count()).toBe(1); + }); + + it('refuses a learner and deletes nothing', async () => { + const { db, invites } = await setup(); + const made = await invites.create({ who: admin, now }); + await expect(invites.cancel({ who: learner, id: made.id })).rejects.toBeInstanceOf( + NotAdminError + ); + expect(await db.invite.count()).toBe(1); + }); +}); + +describe('lookup', () => { + it('finds a pending Invite by its token', async () => { + const { invites } = await setup(); + const made = await invites.create({ who: admin, now }); + expect(await invites.lookup(made.token, now)).toEqual({ id: made.id }); + }); + + it('refuses a missing, malformed, used or expired token', async () => { + const { db, invites } = await setup(); + const made = await invites.create({ who: admin, now }); + expect(await invites.lookup('A'.repeat(43), now)).toBeNull(); + expect(await invites.lookup('', now)).toBeNull(); + expect(await invites.lookup(hashToken(made.token), now)).toBeNull(); + const justBefore = new Date(now.getTime() + INVITE_LIFETIME_MS - 1); + expect(await invites.lookup(made.token, justBefore)).toEqual({ id: made.id }); + const atExpiry = new Date(now.getTime() + INVITE_LIFETIME_MS); + expect(await invites.lookup(made.token, atExpiry)).toBeNull(); + await db.invite.update({ where: { id: made.id }, data: { usedAt: now } }); + expect(await invites.lookup(made.token, now)).toBeNull(); + }); +}); diff --git a/src/lib/server/identity/invites.ts b/src/lib/server/identity/invites.ts new file mode 100644 index 0000000..fe361d4 --- /dev/null +++ b/src/lib/server/identity/invites.ts @@ -0,0 +1,111 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import type { Db } from '../db.ts'; +import { NotAdminError } from './accounts.ts'; +import type { Identity } from './index.ts'; + +/** An Invite works for 7 days from its creation. */ +export const INVITE_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000; + +/** 32 random bytes as base64url: 43 characters, no padding. */ +const TOKEN_SHAPE = /^[A-Za-z0-9_-]{43}$/; + +/** A pending Invite as the Users page lists it. Nothing the token can be rebuilt from is in it. */ +export interface PendingInvite { + id: string; + /** ISO 8601. */ + createdAt: string; + /** ISO 8601. */ + expiresAt: string; +} + +/** What `create` returns: the one and only time the token and the link exist outside the invitee's hands. */ +export interface CreatedInvite { + id: string; + token: string; + link: string; + expiresAt: string; +} + +/** + * The Invite module. An Invite is a link that works once, for one new account, for 7 days. The token + * is 32 random bytes; only its SHA-256 is stored, so the table cannot give a link back. The admin + * sees the link once, when it is created. + * + * `create`, `listPending` and `cancel` are for the admin: `who.role` must be `admin` exactly. + * `lookup` is for the invitee, who has no Account; it judges against a `now` passed in. + */ +export interface Invites { + create(input: { who: Identity; now: Date }): Promise; + /** Not used, not expired; oldest first. */ + listPending(input: { who: Identity; now: Date }): Promise; + /** + * Deletes a pending Invite (the schema has no cancelled state). False, and nothing changed, when + * there is no such unused Invite: unknown, already cancelled and used look the same. + */ + cancel(input: { who: Identity; id: string }): Promise; + /** The Invite this token names, or null when it is missing, used, expired or cancelled. */ + lookup(token: string, now: Date): Promise<{ id: string } | null>; +} + +export const hashToken = (token: string) => createHash('sha256').update(token).digest('hex'); + +export function createInvites({ db, origin }: { db: Db; origin: string }): Invites { + const requireAdmin = (who: Identity) => { + if (who.role !== 'admin') throw new NotAdminError(); + }; + + return { + async create({ who, now }) { + requireAdmin(who); + // Identity names a Learner; the Invite belongs to the User that Learner signed in as. + const user = await db.user.findUnique({ + where: { learnerId: who.learnerId }, + select: { id: true } + }); + if (!user) throw new NotAdminError(); + const token = randomBytes(32).toString('base64url'); + const expiresAt = new Date(now.getTime() + INVITE_LIFETIME_MS); + const id = randomUUID(); + await db.invite.create({ + data: { + id, + tokenHash: hashToken(token), + createdAt: now, + expiresAt, + createdByUserId: user.id + } + }); + return { id, token, link: `${origin}/invite/${token}`, expiresAt: expiresAt.toISOString() }; + }, + + async listPending({ who, now }) { + requireAdmin(who); + const rows = await db.invite.findMany({ + where: { usedAt: null, expiresAt: { gt: now } }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + select: { id: true, createdAt: true, expiresAt: true } + }); + return rows.map((row) => ({ + id: row.id, + createdAt: row.createdAt.toISOString(), + expiresAt: row.expiresAt.toISOString() + })); + }, + + async cancel({ who, id }) { + requireAdmin(who); + const { count } = await db.invite.deleteMany({ where: { id, usedAt: null } }); + return count > 0; + }, + + async lookup(token, now) { + if (!TOKEN_SHAPE.test(token)) return null; + const row = await db.invite.findUnique({ + where: { tokenHash: hashToken(token) }, + select: { id: true, usedAt: true, expiresAt: true } + }); + if (!row || row.usedAt !== null || row.expiresAt.getTime() <= now.getTime()) return null; + return { id: row.id }; + } + }; +} diff --git a/src/routes/signup/signup-gate.test.ts b/src/routes/signup/signup-gate.test.ts index eddac6a..ad522ad 100644 --- a/src/routes/signup/signup-gate.test.ts +++ b/src/routes/signup/signup-gate.test.ts @@ -4,6 +4,7 @@ import type { App } from '$lib/server/app'; import { createHandle } from '$lib/server/handle'; import { createAuth } from '$lib/server/identity/auth'; import { createLocalIdentity } from '$lib/server/identity/index'; +import { createInvites } from '$lib/server/identity/invites'; import { createMultiUserIdentity } from '$lib/server/identity/multi-user'; import { createRegistration } from '$lib/server/identity/registration'; import { createInstance } from '$lib/server/instance'; @@ -31,7 +32,11 @@ async function app(mode: 'local' | 'multi' | null) { secret: 'test-secret-test-secret-test-secret-0123', admitSignUp: createRegistration({ instance }) }); - multiUser = { auth, identity: createMultiUserIdentity(auth) }; + multiUser = { + auth, + identity: createMultiUserIdentity(auth), + invites: createInvites({ db, origin: 'http://localhost:3000' }) + }; } const core = { forLearner: (learnerId: string) => ({ learnerId }) } as unknown as App['core']; const setup = { multiUser: { kind: 'later' }, createFirstAdmin: vi.fn() } as App['setup']; diff --git a/src/routes/users/+page.server.ts b/src/routes/users/+page.server.ts index 5a044c9..591bece 100644 --- a/src/routes/users/+page.server.ts +++ b/src/routes/users/+page.server.ts @@ -8,16 +8,17 @@ import type { Actions, PageServerLoad } from './$types'; * already sent a signed-out visitor to Sign in, given a learner 403 and hidden the page in Local * Mode (404); this load checks again and fails closed, because it is the one that reads the Accounts. */ -export const load: PageServerLoad = async ({ locals, request, setHeaders }) => { - const { auth, identity } = locals; +export const load: PageServerLoad = async ({ locals, request }) => { + const { auth, identity, invites } = locals; // Better Auth is set only in Multi-user Mode: Local Mode has no Accounts, whatever role it gives the Local Learner. if (!auth) error(404, 'Not found'); if (!identity) error(403, 'Forbidden'); try { const accounts = await listAccounts(auth, { who: identity, headers: request.headers }); - // Names and emails of everyone: not for a shared cache, nor for the back button after sign-out. - setHeaders({ 'Cache-Control': 'no-store' }); - return { accounts, registration: locals.instance.registrationMode() }; + // Set exactly when `auth` is; fail closed anyway. After the Accounts, whose read refuses a learner first. + if (!invites) error(404, 'Not found'); + const pending = await invites.listPending({ who: identity, now: new Date() }); + return { accounts, pending, registration: locals.instance.registrationMode() }; } catch (thrown) { if (thrown instanceof NotAdminError) error(403, 'Forbidden'); throw thrown; @@ -54,5 +55,46 @@ export const actions: Actions = { return fail(500, { message: 'Registration could not be changed. Try again.' }); } return { registration: choice }; + }, + + /** + * Makes an Invite and answers with its link: the one response that ever carries the token. It is + * never cached (the hook sets `no-store` on every /users response), never put in a redirect, and not stored (only its hash is). Admin only, checked first. + */ + invite: async ({ locals }) => { + const { identity, invites } = locals; + if (identity?.role !== 'admin') error(403, 'Forbidden'); + if (!invites) error(404, 'Not found'); + try { + const made = await invites.create({ who: identity, now: new Date() }); + return { invite: { link: made.link, expiresAt: made.expiresAt } }; + } catch (thrown) { + if (thrown instanceof NotAdminError) error(403, 'Forbidden'); + console.error( + 'Users: creating an Invite failed:', + thrown instanceof Error ? thrown.name : typeof thrown + ); + return fail(500, { message: 'The invite could not be created. Try again.' }); + } + }, + + /** Cancels a pending Invite. An unknown, used or already-cancelled one gets the same fixed message. */ + cancel: async ({ locals, request }) => { + const { identity, invites } = locals; + if (identity?.role !== 'admin') error(403, 'Forbidden'); + if (!invites) error(404, 'Not found'); + const id = (await request.formData()).get('id'); + try { + const cancelled = typeof id === 'string' && (await invites.cancel({ who: identity, id })); + if (!cancelled) return fail(404, { message: 'That invite is no longer pending.' }); + } catch (thrown) { + if (thrown instanceof NotAdminError) error(403, 'Forbidden'); + console.error( + 'Users: cancelling an Invite failed:', + thrown instanceof Error ? thrown.name : typeof thrown + ); + return fail(500, { message: 'The invite could not be cancelled. Try again.' }); + } + return { cancelled: true }; } }; diff --git a/src/routes/users/+page.svelte b/src/routes/users/+page.svelte index a67903e..556bec0 100644 --- a/src/routes/users/+page.svelte +++ b/src/routes/users/+page.svelte @@ -16,6 +16,31 @@ const explanation = { open: 'Open: anyone who can reach this instance can create an account.' }; +// "Done" hides the link; it cannot be shown again, so it stays hidden until the next Invite is made. +let dismissed = $state(false); +let copied = $state(false); +let linkField = $state(); +const invite = $derived(dismissed ? undefined : form && 'invite' in form ? form.invite : undefined); + +async function copyLink() { + if (!invite) return; + try { + await navigator.clipboard.writeText(invite.link); + copied = true; + } catch { + // No clipboard access (plain http, or refused): select the link so Ctrl+C works. + linkField?.select(); + } +} + +const makeInvite: SubmitFunction = () => { + dismissed = false; + copied = false; + return async ({ update }) => { + await update({ reset: false }); + }; +}; + // A refused or failed switch changed nothing: the select goes back to what the Instance holds, so it // never shows a mode that is not in force. const switchRegistration: SubmitFunction = ({ formElement }) => { @@ -63,6 +88,46 @@ const switchRegistration: SubmitFunction = ({ formElement }) => { +
+
+

Invite a user

+

Creates a link for one new account.

+
+
+
+ +
+ + {#if invite} +
+ Invite link ready +
+ e.currentTarget.select()} + /> + {#if enhanced} + + {/if} +
+ + There is no email. Copy this link and send it to the person yourself. It works for one + account, for 7 days. + + {#if enhanced} + + {/if} +
+ {/if} +
+
+

All users

@@ -93,6 +158,20 @@ const switchRegistration: SubmitFunction = ({ formElement }) => { {/each} + {#each data.pending as pending (pending.id)} + + Pending invite + — + — + invited + +
+ + +
+ + + {/each}
@@ -206,6 +285,61 @@ const switchRegistration: SubmitFunction = ({ formElement }) => { font-size: 15px; font-weight: 500; } + .control .outline { + background: transparent; + color: var(--ink); + border: 1px solid var(--ink); + } + .detail { + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px 0 0; + border-top: 1px solid var(--rule); + } + .eyebrow.ink { + color: var(--ink); + font-size: 12px; + } + .copy { + display: flex; + gap: 12px; + flex-wrap: wrap; + } + .copy input { + flex: 1 1 280px; + min-width: 0; + height: 52px; + box-sizing: border-box; + padding: 0 16px; + background: var(--field); + border: 1px solid var(--ink); + border-radius: 2px; + color: var(--ink); + font-family: var(--font-mono); + font-size: 14px; + } + .small { + font-size: 16px; + } + .control .quiet, + td .quiet { + height: auto; + padding: 12px 0; + line-height: 20px; + background: transparent; + border: 0; + color: var(--ink); + font-family: var(--font-mono); + font-size: 14px; + font-weight: 400; + cursor: pointer; + } + .pending { + font-size: 21px; + font-style: italic; + color: var(--muted); + } .lead { margin: 0; font-size: 18px; diff --git a/src/routes/users/registration.multi.e2e.ts b/src/routes/users/registration.multi.e2e.ts index 79ce3d4..c739f59 100644 --- a/src/routes/users/registration.multi.e2e.ts +++ b/src/routes/users/registration.multi.e2e.ts @@ -99,7 +99,8 @@ test.describe('on a Multi-user server of its own', () => { await expect(registration(page).getByRole('option')).toHaveText(['Invite only', 'Open']); await expect(page.getByText(INVITE_ONLY)).toBeVisible(); await expect(page.getByText(OPEN)).toHaveCount(0); - await expect(headings.nth(1)).toHaveText('All users'); + await expect(headings.nth(1)).toHaveText('Invite a user'); + await expect(headings.nth(2)).toHaveText('All users'); await context.close(); }); diff --git a/src/routes/users/users.multi.e2e.ts b/src/routes/users/users.multi.e2e.ts index c31f9eb..7e49346 100644 --- a/src/routes/users/users.multi.e2e.ts +++ b/src/routes/users/users.multi.e2e.ts @@ -130,3 +130,30 @@ test('a visitor who is signed out is sent to Sign in, not shown the page', async await expect(page).toHaveURL(/\/signin\?next=%2Fusers$/); await expect(page.getByRole('table')).toHaveCount(0); }); + +test.describe('without JavaScript', () => { + test.use({ javaScriptEnabled: false }); + + test('a plain form post creates an Invite, shows the link once and is never cached', async ({ + context, + page + }) => { + await context.addCookies(admin.cookies); + await page.goto('/users'); + const posted = page.waitForResponse( + (r) => r.request().method() === 'POST' && r.url().includes('/users?/invite') + ); + + await page.getByRole('button', { name: 'Invite a user' }).click(); + + // SvelteKit ran the action and then the load in one request: the answer is the page, not a 500. + const response = await posted; + expect(response.status()).toBe(200); + expect(response.headers()['cache-control']).toBe('no-store'); + await expect(page.getByLabel('Invite link')).toHaveValue(/\/invite\/[\w-]{43}$/); + + // Clean up, so a later file sees no stray pending Invite. + await page.getByRole('button', { name: 'Cancel invite' }).first().click(); + await expect(page.getByLabel('Invite link')).toHaveCount(0); + }); +}); diff --git a/src/routes/users/users.test.ts b/src/routes/users/users.test.ts index 9654873..10393cf 100644 --- a/src/routes/users/users.test.ts +++ b/src/routes/users/users.test.ts @@ -5,6 +5,7 @@ import type { Db } from '$lib/server/db'; import { createHandle } from '$lib/server/handle'; import { createAuth } from '$lib/server/identity/auth'; import { createLocalIdentity } from '$lib/server/identity/index'; +import { createInvites } from '$lib/server/identity/invites'; import { createMultiUserIdentity } from '$lib/server/identity/multi-user'; import { createRegistration } from '$lib/server/identity/registration'; import { createFirstAdmin } from '$lib/server/identity/setup'; @@ -51,16 +52,17 @@ async function setup() { return cookieOf(signedUp.headers); } + const invites = createInvites({ db, origin: 'http://localhost:3000' }); const core = { forLearner: (learnerId: string) => ({ learnerId }) } as unknown as App['core']; const setupPort = { multiUser: { kind: 'open' }, createFirstAdmin: vi.fn() } as App['setup']; const handle = createHandle({ instance, identity: await createLocalIdentity(db), core, - multiUser: { auth, identity: createMultiUserIdentity(auth) }, + multiUser: { auth, identity: createMultiUserIdentity(auth), invites }, setup: setupPort }); - return { db, auth, instance, admin, learner, handle }; + return { db, auth, instance, admin, learner, handle, invites }; } /** Runs one request through the hook; the page's load runs where SvelteKit would run it. */ @@ -124,7 +126,7 @@ describe('the Users page in Multi-user Mode', () => { await learner('Lin Learner', 'lin@example.com'); await learner('Max Learner', 'max@example.com'); - const { response, setHeaders } = await visit(handle, '/users', admin); + const { response } = await visit(handle, '/users', admin); expect(response.status).toBe(200); const { accounts } = await response.json(); @@ -134,7 +136,23 @@ describe('the Users page in Multi-user Mode', () => { 'max@example.com' ]); expect(accounts.map((a: { you: boolean }) => a.you)).toEqual([true, false, false]); - expect(setHeaders).toHaveBeenCalledWith({ 'Cache-Control': 'no-store' }); + expect(response.headers.get('cache-control')).toBe('no-store'); + }); + + it('sets no-store in the hook, whatever the route answers, so a route never sets it twice', async () => { + const { handle, admin } = await setup(); + for (const status of [200, 500]) { + const event = { + url: new URL('/users', 'http://localhost'), + route: { id: '/users' }, + request: { method: 'POST', headers: new Headers({ cookie: admin }) }, + cookies: { get: () => undefined, set: vi.fn() }, + locals: {} + } as unknown as RequestEvent; + const resolve = vi.fn(async () => new Response('x', { status })); + const response = await handle({ event, resolve }); + expect(response.headers.get('cache-control'), `${status}`).toBe('no-store'); + } }); it('gives a learner 403 before the page loads', async () => { @@ -467,3 +485,158 @@ describe('the Registration action, if the hook ever let a request through', () = expect(i.setRegistrationMode).toHaveBeenCalledExactlyOnceWith('open'); }); }); + +/** Posts to the Invite actions through the real hook. */ +async function post( + handle: ReturnType, + cookie: string, + name: 'invite' | 'cancel', + fields: Record = {} +) { + const setHeaders = vi.fn(); + const resolve = vi.fn(async (event: RequestEvent, _opts?: ResolveOptions) => { + const result = await actions[name]({ ...event, setHeaders } as never); + return Response.json(result ?? null, { status: result?.status ?? 200 }); + }); + const event = { + url: new URL(`/users?/${name}`, 'http://localhost'), + route: { id: '/users' }, + request: new Request(`http://localhost/users?/${name}`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + ...(cookie ? { cookie } : {}) + }, + body: new URLSearchParams(fields) + }), + cookies: { get: () => undefined, set: vi.fn() }, + locals: {} + } as unknown as RequestEvent; + const response = await handle({ event, resolve }); + return { response, resolve, setHeaders }; +} + +describe('Invites on the Users page', () => { + it('lets the admin create an Invite: the link is in that one response, the database holds only a hash', async () => { + const { handle, admin, db } = await setup(); + + const { response } = await post(handle, admin, 'invite'); + + expect(response.status).toBe(200); + const text = JSON.stringify(await response.json()); + const link = text.match(/http:\\?\/\\?\/localhost:3000\\?\/invite\\?\/([\w-]{43})/); + expect(link).toBeTruthy(); + const token = (link as RegExpMatchArray)[1]; + expect(JSON.stringify(await db.invite.findMany())).not.toContain(token); + expect(response.headers.get('cache-control')).toBe('no-store'); + }); + + it('lists pending Invites in the load, without any token', async () => { + const { handle, admin, invites } = await setup(); + const made = await invites.create({ + who: { learnerId: 'local', displayName: 'Ada', role: 'admin' }, + now: new Date() + }); + + const { response } = await visit(handle, '/users', admin); + + const body = await response.json(); + expect(body.pending).toEqual([expect.objectContaining({ id: made.id })]); + expect(JSON.stringify(body)).not.toContain(made.token); + }); + + it('cancels a pending Invite; the cancelled link no longer works', async () => { + const { handle, admin, invites } = await setup(); + const made = await invites.create({ + who: { learnerId: 'local', displayName: 'Ada', role: 'admin' }, + now: new Date() + }); + + const { response } = await post(handle, admin, 'cancel', { id: made.id }); + + expect(response.status).toBe(200); + expect(await invites.lookup(made.token, new Date())).toBeNull(); + }); + + it.each([ + ['an unknown id', { id: 'nope' }], + ['no id', {}] + ])('answers %s with a fixed message', async (_, fields) => { + const { handle, admin } = await setup(); + + const { response } = await post(handle, admin, 'cancel', fields); + + expect(response.status).toBe(404); + expect(JSON.stringify(await response.json())).toContain('no longer pending'); + }); + + it.each(['invite', 'cancel'] as const)( + '%s: refuses a learner with 403 and writes nothing', + async (name) => { + const { handle, learner, db, invites } = await setup(); + const lin = await learner('Lin Learner', 'lin@example.com'); + const made = await invites.create({ + who: { learnerId: 'local', displayName: 'Ada', role: 'admin' }, + now: new Date() + }); + + const { response, resolve } = await post(handle, lin, name, { id: made.id }); + + expect(response.status).toBe(403); + expect(resolve).not.toHaveBeenCalled(); + expect(await db.invite.count()).toBe(1); + } + ); + + it.each(['invite', 'cancel'] as const)( + '%s: sends a signed-out request to Sign in', + async (name) => { + const { handle, db } = await setup(); + + const { response, resolve } = await post(handle, '', name, { id: 'x' }); + + expect(response.status).toBe(303); + expect(resolve).not.toHaveBeenCalled(); + expect(await db.invite.count()).toBe(0); + } + ); + + it.each(['invite', 'cancel'] as const)('%s: does not exist in Local Mode', async (name) => { + const db = await createFileTestDb(); + const instance = await createInstance(db); + await instance.chooseLocal(); + const identity = await createLocalIdentity(db); + await identity.ensureLocalLearner(); + const core = { forLearner: (learnerId: string) => ({ learnerId }) } as unknown as App['core']; + const handle = createHandle({ + instance, + identity, + core, + multiUser: null, + setup: { multiUser: { kind: 'later' }, createFirstAdmin: vi.fn() } as App['setup'] + }); + + const { response, resolve } = await post(handle, '', name, { id: 'x' }); + + expect(response.status).toBe(404); + expect(resolve).not.toHaveBeenCalled(); + expect(await db.invite.count()).toBe(0); + }); + + it.each(['invite', 'cancel'] as const)( + '%s: is 403 for a non-admin, 404 without Invites, even past the hook', + async (name) => { + const learner = { learnerId: 'l', displayName: 'N', role: 'learner' as const }; + const admin = { ...learner, role: 'admin' as const }; + const event = (locals: Record) => + ({ locals, setHeaders: vi.fn(), request: { formData: vi.fn() } }) as never; + await expect(actions[name](event({ identity: learner, invites: {} }))).rejects.toMatchObject({ + status: 403 + }); + await expect(actions[name](event({}))).rejects.toMatchObject({ status: 403 }); + await expect(actions[name](event({ identity: admin }))).rejects.toMatchObject({ + status: 404 + }); + } + ); +});