diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 8581e32..4ec5ddf 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -375,6 +375,18 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/admin-users.spec.ts + - name: Reset login rate limit before invitations pack + run: | + echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ + pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" + + # Invitations (issue #332) need the mail catcher like the auth pack: + # the invite link and the follow-up verification both travel by mail. + - name: Run invitations pack + run: | + E2E_BASE_URL=http://localhost:5173 E2E_MAILPIT_URL=http://mailpit:8025 \ + pnpm --filter @dorfteich/web exec playwright test e2e/invitations.spec.ts + - name: Reset login rate limit before permission-matrix pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ diff --git a/apps/api/prisma/migrations/20260805120000_invitations/migration.sql b/apps/api/prisma/migrations/20260805120000_invitations/migration.sql new file mode 100644 index 0000000..2afe2be --- /dev/null +++ b/apps/api/prisma/migrations/20260805120000_invitations/migration.sql @@ -0,0 +1,26 @@ +-- Peer invitations (issue #332): a user invites an e-mail address; the token +-- allows exactly one registration even while registration is closed. + +-- CreateTable +CREATE TABLE "invitations" ( + "id" TEXT NOT NULL, + "inviter_id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "revoked_at" TIMESTAMP(3), + "accepted_at" TIMESTAMP(3), + "accepted_user_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "invitations_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "invitations_token_hash_key" ON "invitations"("token_hash"); + +-- CreateIndex +CREATE INDEX "invitations_inviter_id_idx" ON "invitations"("inviter_id"); + +-- AddForeignKey +ALTER TABLE "invitations" ADD CONSTRAINT "invitations_inviter_id_fkey" FOREIGN KEY ("inviter_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 498672b..722b98d 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -68,10 +68,34 @@ model User { notifications Notification[] favorites PageFavorite[] customFonts CustomFont[] + invitations Invitation[] @relation("InvitationsSent") @@map("users") } +/// Peer invitations (issue #332): a user invites an e-mail address; the +/// token allows exactly one registration even while registration is +/// closed. Only the SHA-256 hash of the token is stored (auth-tokens +/// pattern); revoked/accepted rows are kept so the settings UI can show +/// history. "Open" (pending, unexpired) rows count against the per-user +/// quota `invitations.maxOpenPerUser`. +model Invitation { + id String @id @default(uuid()) + inviterId String @map("inviter_id") + email String + tokenHash String @unique @map("token_hash") + expiresAt DateTime @map("expires_at") + revokedAt DateTime? @map("revoked_at") + acceptedAt DateTime? @map("accepted_at") + acceptedUserId String? @map("accepted_user_id") + createdAt DateTime @default(now()) @map("created_at") + + inviter User @relation("InvitationsSent", fields: [inviterId], references: [id], onDelete: Cascade) + + @@index([inviterId]) + @@map("invitations") +} + /// Persistent audit trail (issue #86, security.md §Logging): auth events and /// admin actions — grants, member roles, plugin installs, quota and settings /// changes, setup steps, manual job triggers. Written by AuditService, which diff --git a/apps/api/src/audit/audit-actions.ts b/apps/api/src/audit/audit-actions.ts index cfcffe8..4e5c44d 100644 --- a/apps/api/src/audit/audit-actions.ts +++ b/apps/api/src/audit/audit-actions.ts @@ -29,6 +29,9 @@ export const AUDIT_EVENTS = { 'file.integrity_failed': { severity: 'critical' }, 'grant.created': { severity: 'notice' }, 'grant.deleted': { severity: 'notice' }, + 'invitation.accepted': { severity: 'notice' }, + 'invitation.created': { severity: 'info' }, + 'invitation.revoked': { severity: 'info' }, 'job.triggered': { severity: 'info' }, 'member.added': { severity: 'notice' }, 'member.removed': { severity: 'notice' }, diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 1a376ae..b3fad2f 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -3,6 +3,7 @@ import { APP_GUARD } from '@nestjs/core'; import { AppConfig } from '../config/app-config.service'; import { GrantsModule } from '../grants/grants.module'; +import { InvitationsModule } from '../invitations/invitations.module'; import { MailModule } from '../mail/mail.module'; import { PondsModule } from '../ponds/ponds.module'; @@ -18,7 +19,7 @@ import { ProxyIdentityService } from './proxy-identity.service'; import { SessionsModule } from './sessions.module'; @Module({ - imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule], + imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule, InvitationsModule], controllers: [AuthController, OidcController], providers: [ AuthService, diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 5e12786..fde76d3 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -9,6 +9,7 @@ import { User } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; import { AppConfig } from '../config/app-config.service'; +import { InvitationsService } from '../invitations/invitations.service'; import { MailService } from '../mail/mail.service'; import { PondsService } from '../ponds/ponds.service'; import { AuditService } from '../audit/audit.service'; @@ -33,6 +34,7 @@ export class AuthService { private readonly sessions: SessionsService, private readonly mail: MailService, private readonly ponds: PondsService, + private readonly invitations: InvitationsService, private readonly rateLimits: RateLimitService, private readonly audit: AuditService, private readonly config: AppConfig, @@ -43,10 +45,37 @@ export class AuthService { } async signup(input: SignupInput): Promise { - if ((await this.settings.get('auth.registrationMode')) === 'closed') { + // An invitation token (issue #332) lets exactly one signup through a + // closed registration. Claimed atomically BEFORE the account exists; + // rolled back if the signup fails (duplicate username), so the invitee + // can retry with the same link. + const invitation = input.invitationToken + ? await this.invitations.redeem(input.invitationToken) + : null; + if (input.invitationToken && !invitation) { + throw new BadRequestException({ code: 'token_invalid' }); + } + if (!invitation && (await this.settings.get('auth.registrationMode')) === 'closed') { throw new ForbiddenException({ code: 'registration_closed' }); } - const user = await this.users.createUser(input); + let user: User; + try { + user = await this.users.createUser(input); + } catch (error) { + if (invitation) await this.invitations.unredeem(invitation.id); + throw error; + } + if (invitation) { + await this.invitations.markAccepted(invitation.id, user.id); + await this.audit.record({ + action: 'invitation.accepted', + actorId: user.id, + targetType: 'invitation', + targetId: invitation.id, + }); + } + // The invite link proves nothing about the mailbox (it can be + // forwarded), so the usual verification mail still applies. await this.sendVerificationMail(user); await this.audit.record({ action: 'auth.signup', actorId: user.id }); } diff --git a/apps/api/src/invitations/invitations.controller.ts b/apps/api/src/invitations/invitations.controller.ts new file mode 100644 index 0000000..b1dbd6f --- /dev/null +++ b/apps/api/src/invitations/invitations.controller.ts @@ -0,0 +1,50 @@ +import { Body, Controller, Delete, Get, HttpCode, Param, Post, Req } from '@nestjs/common'; +import { + CreateInvitationInput, + InvitationListView, + InvitationPreview, + InvitationView, + createInvitationSchema, + invitationPreviewSchema, +} from '@dorfteich/shared'; + +import { AuthedRequest, Public } from '../auth/auth.guard'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { AuthenticatedOnly } from '../permissions/permission.decorators'; +import { InvitationsService } from './invitations.service'; + +/** Peer invitations (issue #332). */ +@AuthenticatedOnly() +@Controller('invitations') +export class InvitationsController { + constructor(private readonly invitations: InvitationsService) {} + + @Post() + async create( + @Body(new ZodValidationPipe(createInvitationSchema)) input: CreateInvitationInput, + @Req() request: AuthedRequest, + ): Promise { + return this.invitations.create(request.user!, input.email); + } + + @Get() + async list(@Req() request: AuthedRequest): Promise { + return this.invitations.list(request.user!); + } + + @Delete(':id') + @HttpCode(204) + async revoke(@Param('id') id: string, @Req() request: AuthedRequest): Promise { + await this.invitations.revoke(request.user!, id); + } + + /** The signup screen's link check — POST keeps the token out of logs. */ + @Public() + @Post('preview') + @HttpCode(200) + async preview( + @Body(new ZodValidationPipe(invitationPreviewSchema)) input: { token: string }, + ): Promise { + return this.invitations.preview(input.token); + } +} diff --git a/apps/api/src/invitations/invitations.e2e.db.test.ts b/apps/api/src/invitations/invitations.e2e.db.test.ts new file mode 100644 index 0000000..a0fc0b2 --- /dev/null +++ b/apps/api/src/invitations/invitations.e2e.db.test.ts @@ -0,0 +1,246 @@ +import { INestApplication } from '@nestjs/common'; +import { InvitationListView, InvitationPreview, InvitationView } from '@dorfteich/shared'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { InstanceSettingsService } from '../settings/instance-settings.service'; +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +/** + * Peer invitations end to end (issue #332): inviting mails a single-use + * link, open invitations are quota-bound per user, and a valid token lets + * exactly one signup through a closed registration. Settings written here + * are restored inside each test and the keys are deleted in afterAll + * (shared-DB rule). + */ +describe.skipIf(!hasTestDb)('invitations (e2e, issue #332)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + const password = 'einladungen sind praktisch 1'; + const ids: Record = {}; + const cookies: Record = {}; + + const api = () => request(app.getHttpServer()); + const settings = () => app.get(InstanceSettingsService); + + async function makeUser(handle: string): Promise { + const users = app.get(UsersService); + const username = `inv-${handle}-${suffix}`; + const user = await users.createUser({ + username, + email: `${username}@example.org`, + displayName: `Inv ${handle}`, + password, + locale: 'en', + }); + await users.markEmailVerified(user.id); + ids[handle] = user.id; + cookies[handle] = sessionCookieOf( + await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: username, password }) + .expect(200), + ); + } + + /** The raw token only travels in the mail — fish it out of the outbox. */ + async function mailedTokenFor(email: string): Promise { + const mail = await prisma.mailOutbox.findFirstOrThrow({ + where: { toAddress: email }, + orderBy: { createdAt: 'desc' }, + }); + const match = /invitation=([A-Za-z0-9_-]+)/.exec(mail.textBody); + expect(match).not.toBeNull(); + return match![1]!; + } + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + app = await createTestApp(); + await makeUser('alice'); + await makeUser('quota'); + }); + + afterAll(async () => { + await prisma.instanceSetting.deleteMany({ + where: { key: { in: ['auth.registrationMode', 'invitations.maxOpenPerUser'] } }, + }); + const all = Object.values(ids); + await prisma.invitation.deleteMany({ where: { inviterId: { in: all } } }); + await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } }); + await prisma.session.deleteMany({ where: { userId: { in: all } } }); + await deletePondsWhere(prisma, { ownerId: { in: all } }); + await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } }); + await prisma.user.deleteMany({ where: { id: { in: all } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('invites, lists, and mails a single-use signup link', async () => { + const invitee = `guest-${suffix}@example.org`; + const res = await api() + .post('/api/v1/invitations') + .set('Cookie', cookies.alice!) + .send({ email: invitee }) + .expect(201); + const view = res.body as InvitationView; + expect(view.status).toBe('pending'); + + const list = (await api().get('/api/v1/invitations').set('Cookie', cookies.alice!).expect(200)) + .body as InvitationListView; + expect(list.open).toBe(1); + expect(list.maxOpen).toBe(5); + expect(list.invitations.map((i) => i.id)).toContain(view.id); + + // The mail exists and the public preview identifies the inviter. + const token = await mailedTokenFor(invitee); + const preview = (await api().post('/api/v1/invitations/preview').send({ token }).expect(200)) + .body as InvitationPreview; + expect(preview.email).toBe(invitee); + expect(preview.inviterName).toBe('Inv alice'); + }); + + it('a valid token passes a closed registration exactly once; a burned signup attempt does not consume it', async () => { + const invitee = `joiner-${suffix}@example.org`; + await api() + .post('/api/v1/invitations') + .set('Cookie', cookies.alice!) + .send({ email: invitee }) + .expect(201); + const token = await mailedTokenFor(invitee); + + await settings().set('auth.registrationMode', 'closed', ids.alice!); + try { + // Closed without a token: refused. + await api() + .post('/api/v1/auth/signup') + .send({ + username: `inv-blocked-${suffix}`, + email: `inv-blocked-${suffix}@example.org`, + displayName: 'Blocked', + password, + locale: 'en', + }) + .expect(403); + + // A failing signup (taken username) must NOT burn the token. + await api() + .post('/api/v1/auth/signup') + .send({ + username: `inv-alice-${suffix}`, // taken + email: invitee, + displayName: 'Joiner', + password, + locale: 'en', + invitationToken: token, + }) + .expect(409); + + // Same link, fresh username: through, despite closed mode. + const username = `inv-joiner-${suffix}`; + await api() + .post('/api/v1/auth/signup') + .send({ + username, + email: invitee, + displayName: 'Joiner', + password, + locale: 'en', + invitationToken: token, + }) + .expect(201); + const joiner = await prisma.user.findUniqueOrThrow({ where: { username } }); + ids.joiner = joiner.id; + + // The invitation is tied to the new account… + const accepted = await prisma.invitation.findFirstOrThrow({ + where: { acceptedUserId: joiner.id }, + }); + expect(accepted.acceptedAt).not.toBeNull(); + + // …and the token is single-use. + await api() + .post('/api/v1/auth/signup') + .send({ + username: `inv-replay-${suffix}`, + email: `inv-replay-${suffix}@example.org`, + displayName: 'Replay', + password, + locale: 'en', + invitationToken: token, + }) + .expect(400); + } finally { + await settings().set('auth.registrationMode', 'open', ids.alice!); + } + }); + + it('enforces the open-invitations quota and frees it on revoke', async () => { + await settings().set('invitations.maxOpenPerUser', 2, ids.alice!); + try { + const first = ( + await api() + .post('/api/v1/invitations') + .set('Cookie', cookies.quota!) + .send({ email: `q1-${suffix}@example.org` }) + .expect(201) + ).body as InvitationView; + await api() + .post('/api/v1/invitations') + .set('Cookie', cookies.quota!) + .send({ email: `q2-${suffix}@example.org` }) + .expect(201); + await api() + .post('/api/v1/invitations') + .set('Cookie', cookies.quota!) + .send({ email: `q3-${suffix}@example.org` }) + .expect(400) + .expect((r) => expect((r.body as { code: string }).code).toBe('invitation_quota_reached')); + + // Revoking an open invitation frees the slot… + await api() + .delete(`/api/v1/invitations/${first.id}`) + .set('Cookie', cookies.quota!) + .expect(204); + await api() + .post('/api/v1/invitations') + .set('Cookie', cookies.quota!) + .send({ email: `q3-${suffix}@example.org` }) + .expect(201); + + // …and the revoked token is dead. + const revokedToken = await mailedTokenFor(`q1-${suffix}@example.org`); + await api().post('/api/v1/invitations/preview').send({ token: revokedToken }).expect(400); + } finally { + await settings().set('invitations.maxOpenPerUser', 5, ids.alice!); + } + }); + + it('quota 0 disables inviting entirely', async () => { + await settings().set('invitations.maxOpenPerUser', 0, ids.alice!); + try { + await api() + .post('/api/v1/invitations') + .set('Cookie', cookies.alice!) + .send({ email: `off-${suffix}@example.org` }) + .expect(403) + .expect((r) => expect((r.body as { code: string }).code).toBe('invitations_disabled')); + } finally { + await settings().set('invitations.maxOpenPerUser', 5, ids.alice!); + } + }); + + it('requires a session for create/list/revoke but not for preview', async () => { + await api().post('/api/v1/invitations').send({ email: 'nope@example.org' }).expect(401); + await api().get('/api/v1/invitations').expect(401); + await api() + .post('/api/v1/invitations/preview') + .send({ token: 'x'.repeat(32) }) + .expect(400); + }); +}); diff --git a/apps/api/src/invitations/invitations.module.ts b/apps/api/src/invitations/invitations.module.ts new file mode 100644 index 0000000..c43ad1a --- /dev/null +++ b/apps/api/src/invitations/invitations.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { MailModule } from '../mail/mail.module'; +import { InvitationsController } from './invitations.controller'; +import { InvitationsService } from './invitations.service'; + +@Module({ + imports: [MailModule], + controllers: [InvitationsController], + providers: [InvitationsService], + exports: [InvitationsService], +}) +export class InvitationsModule {} diff --git a/apps/api/src/invitations/invitations.service.ts b/apps/api/src/invitations/invitations.service.ts new file mode 100644 index 0000000..330ff87 --- /dev/null +++ b/apps/api/src/invitations/invitations.service.ts @@ -0,0 +1,199 @@ +import { createHash, randomBytes } from 'node:crypto'; + +import { + BadRequestException, + ForbiddenException, + HttpException, + HttpStatus, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { + InvitationListView, + InvitationPreview, + InvitationStatus, + InvitationView, +} from '@dorfteich/shared'; +import { Invitation, User } from '@prisma/client'; + +import { AuditService } from '../audit/audit.service'; +import { AppConfig } from '../config/app-config.service'; +import { MailService } from '../mail/mail.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { RateLimitService } from '../rate-limit/rate-limit.service'; +import { InstanceSettingsService } from '../settings/instance-settings.service'; + +export const INVITATION_TTL_SECONDS = 14 * 24 * 60 * 60; + +// Anti-spam backstop besides the open-invitations quota: without it a +// revoke-and-recreate loop would allow unlimited mail volume while never +// exceeding the quota. +const CREATE_LIMIT = { limit: 20, windowSeconds: 24 * 60 * 60 }; + +/** + * Peer invitations (issue #332). A user invites an e-mail address; the + * mailed single-use token lets exactly one signup through even while + * registration is closed (auth.service). Open (pending, unexpired) + * invitations count against the per-user quota + * `invitations.maxOpenPerUser` — 0 turns the feature off. Only the + * SHA-256 hash of the token is stored (auth-tokens pattern); revoked and + * accepted rows are kept so the settings UI can show history. + */ +@Injectable() +export class InvitationsService { + constructor( + private readonly prisma: PrismaService, + private readonly mail: MailService, + private readonly rateLimits: RateLimitService, + private readonly settings: InstanceSettingsService, + private readonly audit: AuditService, + private readonly config: AppConfig, + ) {} + + async create(user: User, email: string): Promise { + const maxOpen = (await this.settings.get('invitations.maxOpenPerUser')) as number; + if (maxOpen === 0) throw new ForbiddenException({ code: 'invitations_disabled' }); + if ((await this.openCount(user.id)) >= maxOpen) { + throw new BadRequestException({ code: 'invitation_quota_reached' }); + } + const limit = await this.rateLimits.hit( + 'invitation-create', + user.id, + CREATE_LIMIT.limit, + CREATE_LIMIT.windowSeconds, + ); + if (!limit.allowed) { + throw new HttpException({ code: 'rate_limited' }, HttpStatus.TOO_MANY_REQUESTS); + } + + const raw = randomBytes(32).toString('base64url'); + const row = await this.prisma.invitation.create({ + data: { + inviterId: user.id, + email: email.toLowerCase(), + tokenHash: hashToken(raw), + expiresAt: new Date(Date.now() + INVITATION_TTL_SECONDS * 1000), + }, + }); + // The invitee has no account and no locale yet — the instance default + // decides the mail language. The greeting falls back to the address. + await this.mail.enqueue( + row.email, + 'invitation', + { + displayName: row.email, + inviterName: user.displayName, + link: `${this.config.env.APP_BASE_URL}/signup?invitation=${raw}`, + }, + (await this.settings.get('instance.defaultLocale')) as 'de' | 'en', + ); + await this.audit.record({ + action: 'invitation.created', + actorId: user.id, + targetType: 'invitation', + targetId: row.id, + }); + return this.viewOf(row); + } + + async list(user: User): Promise { + const rows = await this.prisma.invitation.findMany({ + where: { inviterId: user.id }, + orderBy: { createdAt: 'desc' }, + take: 100, + }); + return { + invitations: rows.map((row) => this.viewOf(row)), + open: await this.openCount(user.id), + maxOpen: (await this.settings.get('invitations.maxOpenPerUser')) as number, + }; + } + + async revoke(user: User, id: string): Promise { + const row = await this.prisma.invitation.findFirst({ + where: { id, inviterId: user.id }, + }); + if (!row) throw new NotFoundException(); + if (row.acceptedAt) throw new BadRequestException({ code: 'invitation_already_accepted' }); + if (row.revokedAt) return; // idempotent + await this.prisma.invitation.update({ where: { id }, data: { revokedAt: new Date() } }); + await this.audit.record({ + action: 'invitation.revoked', + actorId: user.id, + targetType: 'invitation', + targetId: id, + }); + } + + /** What the signup screen may show for a link before it is used. */ + async preview(raw: string): Promise { + const row = await this.prisma.invitation.findUnique({ + where: { tokenHash: hashToken(raw) }, + include: { inviter: true }, + }); + if (!row || row.revokedAt || row.acceptedAt || row.expiresAt <= new Date()) { + throw new BadRequestException({ code: 'token_invalid' }); + } + return { email: row.email, inviterName: row.inviter.displayName }; + } + + /** + * Atomically claims the token (only one signup can flip acceptedAt from + * null). Returns the row, or null for unknown/revoked/expired/used + * tokens. The caller un-redeems if the signup fails afterwards. + */ + async redeem(raw: string): Promise { + const result = await this.prisma.invitation.updateMany({ + where: { + tokenHash: hashToken(raw), + revokedAt: null, + acceptedAt: null, + expiresAt: { gt: new Date() }, + }, + data: { acceptedAt: new Date() }, + }); + if (result.count === 0) return null; + return this.prisma.invitation.findUnique({ where: { tokenHash: hashToken(raw) } }); + } + + /** Ties the redeemed invitation to the account it created. */ + async markAccepted(id: string, userId: string): Promise { + await this.prisma.invitation.update({ where: { id }, data: { acceptedUserId: userId } }); + } + + /** Rolls a redeem back when the signup it gated failed (e.g. duplicate + * username) — the invitee must be able to try again with the same link. */ + async unredeem(id: string): Promise { + await this.prisma.invitation.updateMany({ + where: { id, acceptedUserId: null }, + data: { acceptedAt: null }, + }); + } + + private openCount(inviterId: string): Promise { + return this.prisma.invitation.count({ + where: { inviterId, revokedAt: null, acceptedAt: null, expiresAt: { gt: new Date() } }, + }); + } + + private viewOf(row: Invitation): InvitationView { + return { + id: row.id, + email: row.email, + status: statusOf(row), + createdAt: row.createdAt.toISOString(), + expiresAt: row.expiresAt.toISOString(), + }; + } +} + +function statusOf(row: Invitation): InvitationStatus { + if (row.revokedAt) return 'revoked'; + if (row.acceptedAt) return 'accepted'; + if (row.expiresAt <= new Date()) return 'expired'; + return 'pending'; +} + +function hashToken(raw: string): string { + return createHash('sha256').update(raw).digest('hex'); +} diff --git a/apps/api/src/mail/mail-templates.ts b/apps/api/src/mail/mail-templates.ts index d40176c..9161650 100644 --- a/apps/api/src/mail/mail-templates.ts +++ b/apps/api/src/mail/mail-templates.ts @@ -1,6 +1,6 @@ import { apiI18n } from '../i18n/api-i18n'; -export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest'; +export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest' | 'invitation'; export interface RenderedMail { subject: string; @@ -15,14 +15,15 @@ export interface RenderedMail { */ export function renderMail( template: MailTemplate, - params: { displayName: string; link: string }, + // Extra keys (e.g. inviterName, #332) interpolate into the body text. + params: { displayName: string; link: string } & Record, locale: 'de' | 'en', ): RenderedMail { const t = (key: string, options: Record = {}): string => apiI18n.t(`mails:${key}`, { lng: locale, ...options }); const greeting = t('common.greeting', { displayName: params.displayName }); - const body = t(`${template}.body`); + const body = t(`${template}.body`, params); const action = t(`${template}.action`); const expiry = t(`${template}.expiry`); const ignore = t('common.ignoreHint'); diff --git a/apps/api/src/mail/mail.service.ts b/apps/api/src/mail/mail.service.ts index 43c190a..f926548 100644 --- a/apps/api/src/mail/mail.service.ts +++ b/apps/api/src/mail/mail.service.ts @@ -14,7 +14,7 @@ export class MailService { async enqueue( to: string, template: MailTemplate, - params: { displayName: string; link: string }, + params: { displayName: string; link: string } & Record, locale: 'de' | 'en', ): Promise { const rendered = renderMail(template, params, locale); diff --git a/apps/api/src/settings/instance-settings.service.ts b/apps/api/src/settings/instance-settings.service.ts index 33ed550..807db3e 100644 --- a/apps/api/src/settings/instance-settings.service.ts +++ b/apps/api/src/settings/instance-settings.service.ts @@ -21,6 +21,9 @@ import { PrismaService } from '../prisma/prisma.service'; */ export const INSTANCE_SETTINGS = { 'auth.registrationMode': z.enum(['open', 'closed']).default('open'), + // Peer invitations (issue #332): max OPEN (pending, unexpired) + // invitations per user; 0 turns inviting off entirely. + 'invitations.maxOpenPerUser': z.number().int().min(0).default(5), 'instance.name': z.string().trim().min(1).max(60).default('Dorfteich'), 'instance.defaultLocale': z.enum(['de', 'en']).default('en'), // Branding assets (issue #306). Metadata only — the PNG bytes live under diff --git a/apps/web/e2e/a11y.spec.ts b/apps/web/e2e/a11y.spec.ts index bc3c093..06790a1 100644 --- a/apps/web/e2e/a11y.spec.ts +++ b/apps/web/e2e/a11y.spec.ts @@ -87,6 +87,9 @@ for (const scheme of SCHEMES) { await page.emulateMedia({ colorScheme: scheme }); await page.goto('/settings'); await page.waitForLoadState('networkidle'); + // Einladungs-Abschnitt (issue #332) gerendert — sonst liefe der Scan + // auch grün, wenn die Sektion gar nicht erscheint. + await page.locator('.invitations').waitFor(); await expectClean(page, `/settings (${scheme})`); // Lizenzseite im selben Kontext (issue #304: sie trägt seit den diff --git a/apps/web/e2e/invitations.spec.ts b/apps/web/e2e/invitations.spec.ts new file mode 100644 index 0000000..c9b56b2 --- /dev/null +++ b/apps/web/e2e/invitations.spec.ts @@ -0,0 +1,93 @@ +import { expect, test } from '@playwright/test'; + +import { contextForUser, latestMailFor, tokenFromMail } from './helpers'; + +/** + * Peer invitations (issue #332), the full loop through the UI: a user + * invites an address, registration is closed, the invitee registers + * through the mailed link anyway, verifies, and the inviter sees the + * invitation accepted. Needs Mailpit like the auth pack. + */ +const MAILPIT_URL = process.env.E2E_MAILPIT_URL; +test.skip(!MAILPIT_URL, 'requires a Mailpit instance (E2E_MAILPIT_URL)'); + +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +test('invite -> closed registration -> signup through the link -> accepted', async ({ + browser, + page, +}) => { + const stamp = Date.now().toString(36); + const invitee = `invited-${stamp}@dorfteich.test`; + + const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); + const inviter = await contextForUser(browser, BASE_URL, 'fixture-user'); + await admin.request.patch('/api/v1/admin/settings', { + data: { 'auth.registrationMode': 'closed' }, + }); + try { + // Invite through the settings UI. + const settingsPage = await inviter.newPage(); + await settingsPage.goto('/settings'); + const section = settingsPage.locator('.invitations'); + await section.getByLabel(/e-mail/i).fill(invitee); + await section.getByRole('button', { name: /^(invite|einladen)$/i }).click(); + await expect(section.locator('.invitations__sent')).toHaveText(/sent|verschickt/i); + const row = section.locator(`.invitation-row[data-email="${invitee}"]`); + await expect(row.locator('.invitation-row__status')).toHaveText(/open|offen/i); + + // Plain signup is closed… + await page.goto('/signup'); + await expect(page.locator('.form-banner')).toHaveText(/closed|geschlossen/i); + + // …but the mailed link opens the form, inviter banner and prefill included. + const mail = await latestMailFor(MAILPIT_URL!, invitee); + const invitationToken = /invitation=([A-Za-z0-9_-]+)/.exec(mail.text)?.[1]; + expect(invitationToken).toBeTruthy(); + await page.goto(`/signup?invitation=${invitationToken}`); + await expect(page.locator('.signup-invitation__banner')).toBeVisible(); + await expect(page.getByLabel(/e-mail/i)).toHaveValue(invitee); + const username = `invited-${stamp}`; + await page.getByLabel(/username|benutzername/i).fill(username); + await page.getByLabel(/display name|anzeigename/i).fill('Invited Guest'); + await page.getByLabel(/^password|^passwort/i).fill('ein einladungs passwort 1'); + await page.getByRole('button', { name: /register|registrieren/i }).click(); + await expect(page.getByRole('heading', { name: /inbox|postfach/i })).toBeVisible(); + + // The usual verification still applies (the link proves nothing about + // the mailbox). Two mails went to this address — poll for the second. + let verifyToken = ''; + await expect(async () => { + const verifyMail = await latestMailFor(MAILPIT_URL!, invitee); + expect(verifyMail.text).toContain('/verify-email'); + verifyToken = tokenFromMail(verifyMail.text); + }).toPass(); + await page.goto(`/verify-email?token=${verifyToken}`); + await expect(page.getByRole('heading', { name: /confirmed|bestätigt/i })).toBeVisible(); + + // The inviter sees the acceptance; the used link is dead. + await settingsPage.reload(); + await expect( + settingsPage + .locator(`.invitation-row[data-email="${invitee}"]`) + .locator('.invitation-row__status'), + ).toHaveText(/accepted|angenommen/i); + await page.goto(`/signup?invitation=${invitationToken}`); + await expect(page.locator('.signup-invitation__invalid')).toBeVisible(); + + // Revoke flow through the UI: a second invitation dies by revoke. + const second = `revoked-${stamp}@dorfteich.test`; + await section.getByLabel(/e-mail/i).fill(second); + await section.getByRole('button', { name: /^(invite|einladen)$/i }).click(); + const secondRow = section.locator(`.invitation-row[data-email="${second}"]`); + await expect(secondRow.locator('.invitation-row__status')).toHaveText(/open|offen/i); + await secondRow.getByRole('button', { name: /revoke|widerrufen/i }).click(); + await expect(secondRow.locator('.invitation-row__status')).toHaveText(/revoked|widerrufen/i); + } finally { + await admin.request.patch('/api/v1/admin/settings', { + data: { 'auth.registrationMode': 'open' }, + }); + await admin.close(); + await inviter.close(); + } +}); diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index 5e1449f..566faac 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -10,6 +10,7 @@ import deFiles from '@dorfteich/shared/i18n/de/files.json'; import deFont from '@dorfteich/shared/i18n/de/font.json'; import deGraph from '@dorfteich/shared/i18n/de/graph.json'; import deImport from '@dorfteich/shared/i18n/de/import.json'; +import deInvitations from '@dorfteich/shared/i18n/de/invitations.json'; import deLabels from '@dorfteich/shared/i18n/de/labels.json'; import deLegal from '@dorfteich/shared/i18n/de/legal.json'; import deLinks from '@dorfteich/shared/i18n/de/links.json'; @@ -39,6 +40,7 @@ import enFiles from '@dorfteich/shared/i18n/en/files.json'; import enFont from '@dorfteich/shared/i18n/en/font.json'; import enGraph from '@dorfteich/shared/i18n/en/graph.json'; import enImport from '@dorfteich/shared/i18n/en/import.json'; +import enInvitations from '@dorfteich/shared/i18n/en/invitations.json'; import enLabels from '@dorfteich/shared/i18n/en/labels.json'; import enLegal from '@dorfteich/shared/i18n/en/legal.json'; import enLinks from '@dorfteich/shared/i18n/en/links.json'; @@ -85,6 +87,7 @@ void i18n font: enFont, graph: enGraph, import: enImport, + invitations: enInvitations, labels: enLabels, legal: enLegal, links: enLinks, @@ -116,6 +119,7 @@ void i18n font: deFont, graph: deGraph, import: deImport, + invitations: deInvitations, labels: deLabels, legal: deLegal, links: deLinks, diff --git a/apps/web/src/pages/AdminSettingsPage.tsx b/apps/web/src/pages/AdminSettingsPage.tsx index 36957ea..333c8e5 100644 --- a/apps/web/src/pages/AdminSettingsPage.tsx +++ b/apps/web/src/pages/AdminSettingsPage.tsx @@ -25,6 +25,7 @@ import { UserManager } from './UserManager'; import { useDocumentTitle } from '../lib/use-document-title'; interface InstanceSettings { 'auth.registrationMode': 'open' | 'closed'; + 'invitations.maxOpenPerUser': number; 'instance.name': string; 'instance.defaultLocale': 'de' | 'en'; 'quota.editorsPerPond': number; @@ -94,6 +95,10 @@ export function AdminSettingsPage(): React.JSX.Element {

{t('settings:admin.general')}

{(vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) || + vsNfd.hides( + 'invitations.maxOpenPerUser', + settings.data['invitations.maxOpenPerUser'], + ) || vsNfd.hides( 'classification.newPageDefault', settings.data['classification.newPageDefault'], @@ -125,6 +130,25 @@ export function AdminSettingsPage(): React.JSX.Element { + {!vsNfd.hides( + 'invitations.maxOpenPerUser', + settings.data['invitations.maxOpenPerUser'], + ) && ( + + + + )} (null); + const [busy, setBusy] = useState(false); + + const list = useQuery({ + queryKey: INVITATIONS_QUERY_KEY, + queryFn: () => apiGet('/invitations'), + }); + + const submit = async (event: React.FormEvent): Promise => { + event.preventDefault(); + setError(null); + setSent(false); + setBusy(true); + try { + await apiPost('/invitations', { email }); + setEmail(''); + setSent(true); + await queryClient.invalidateQueries({ queryKey: INVITATIONS_QUERY_KEY }); + } catch (err) { + setError(err); + } finally { + setBusy(false); + } + }; + + const revoke = async (id: string): Promise => { + setError(null); + await apiDelete(`/invitations/${id}`); + await queryClient.invalidateQueries({ queryKey: INVITATIONS_QUERY_KEY }); + }; + + const data = list.data; + const disabled = data?.maxOpen === 0; + + return ( +
+

{t('section.title')}

+ {disabled ? ( +

{t('section.disabled')}

+ ) : ( + <> +

{t('section.intro')}

+ {data && ( +

+ {t('section.quota', { open: data.open, max: data.maxOpen })} +

+ )} +
void submit(e)} noValidate className="invitations__form"> + + {/* Scoped status region: a bare getByRole('status') must stay + unambiguous for other specs (lesson from #304/legal). */} +

+ {sent ? t('form.sent') : ''} +

+ + setEmail(e.target.value)} + /> + + + + {data && data.invitations.length === 0 &&

{t('section.empty')}

} + {data && data.invitations.length > 0 && ( +
+ + + + + + + + + + + + {data.invitations.map((invitation) => ( + + + + + + + + ))} + +
{t('columns.email')}{t('columns.status')}{t('columns.created')}{t('columns.expires')}{t('columns.actions')}
{invitation.email}{t(`status.${invitation.status}`)}{new Date(invitation.createdAt).toLocaleDateString()}{new Date(invitation.expiresAt).toLocaleDateString()} + {invitation.status === 'pending' && ( + + )} +
+
+ )} + + )} +
+ ); +} diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 67db9be..d03fb36 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -17,6 +17,7 @@ import { SettingsLayout } from '../components/SettingsLayout'; import { useDataExport } from '../export/use-data-export'; import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api'; import { ApiTokensSection } from '../api-tokens/ApiTokensSection'; +import { InvitationsSection } from './InvitationsSection'; import { FeedTokensSection } from '../api-tokens/FeedTokensSection'; import { WatchesSection } from '../watches/WatchesSection'; @@ -45,6 +46,7 @@ export function SettingsPage(): React.JSX.Element { + diff --git a/apps/web/src/pages/admin-settings-form.test.ts b/apps/web/src/pages/admin-settings-form.test.ts index 002b9a8..8932314 100644 --- a/apps/web/src/pages/admin-settings-form.test.ts +++ b/apps/web/src/pages/admin-settings-form.test.ts @@ -22,6 +22,7 @@ describe('admin general settings form model (issue #322)', () => { 'instance.name': 'My Wiki', 'instance.defaultLocale': 'de', 'auth.registrationMode': 'closed', + 'invitations.maxOpenPerUser': 5, 'classification.newPageDefault': 'unclassified', 'classification.uploadPolicy': 'warn', 'quota.editorsPerPond': 5, @@ -38,6 +39,7 @@ describe('admin general settings form model (issue #322)', () => { instanceName: 'Renamed', defaultLocale: 'en', registrationMode: 'open', + invitationsMaxOpenPerUser: 5, newPageClassification: 'vs_nfd', uploadPolicy: 'block', quotaEditorsPerPond: 1, diff --git a/apps/web/src/pages/admin-settings-form.ts b/apps/web/src/pages/admin-settings-form.ts index 1e3d2b2..28b8e69 100644 --- a/apps/web/src/pages/admin-settings-form.ts +++ b/apps/web/src/pages/admin-settings-form.ts @@ -15,6 +15,7 @@ export const GENERAL_FORM_FIELDS = { instanceName: 'instance.name', defaultLocale: 'instance.defaultLocale', registrationMode: 'auth.registrationMode', + invitationsMaxOpenPerUser: 'invitations.maxOpenPerUser', newPageClassification: 'classification.newPageDefault', uploadPolicy: 'classification.uploadPolicy', quotaEditorsPerPond: 'quota.editorsPerPond', @@ -31,6 +32,7 @@ export interface GeneralSettingsForm { instanceName: string; defaultLocale: 'de' | 'en'; registrationMode: 'open' | 'closed'; + invitationsMaxOpenPerUser: number; newPageClassification: 'unclassified' | 'vs_nfd'; uploadPolicy: 'warn' | 'block'; quotaEditorsPerPond: number; diff --git a/apps/web/src/pages/auth/SignupPage.tsx b/apps/web/src/pages/auth/SignupPage.tsx index c9a7601..058c734 100644 --- a/apps/web/src/pages/auth/SignupPage.tsx +++ b/apps/web/src/pages/auth/SignupPage.tsx @@ -1,10 +1,10 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import { SignupFormInput, signupInputSchema } from '@dorfteich/shared'; +import { InvitationPreview, SignupFormInput, signupInputSchema } from '@dorfteich/shared'; import { useQuery } from '@tanstack/react-query'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Resolver, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router-dom'; +import { Link, useSearchParams } from 'react-router-dom'; import { Field, FormError, applyFieldErrors } from '../../components/forms'; import { apiGet, apiPost } from '../../lib/api'; @@ -21,6 +21,19 @@ export function SignupPage(): React.JSX.Element { queryFn: () => apiGet<{ mode: 'open' | 'closed' }>('/auth/registration'), }); + // An invitation link (issue #332) carries ?invitation=: a valid + // one lets this signup through even while registration is closed. + const [searchParams] = useSearchParams(); + const invitationToken = searchParams.get('invitation'); + const invitation = useQuery({ + queryKey: ['invitation-preview', invitationToken], + queryFn: () => apiPost('/invitations/preview', { token: invitationToken }), + enabled: Boolean(invitationToken), + retry: false, + staleTime: Infinity, + }); + const invited = Boolean(invitationToken) && invitation.isSuccess; + type SignupFormValues = SignupFormInput; // One confined cast: RHF cannot express Zod's input/output split // (locale is optional on input, defaulted on output) without it. @@ -29,10 +42,22 @@ export function SignupPage(): React.JSX.Element { defaultValues: { locale: i18n.language === 'de' ? 'de' : 'en' }, }); + // Prefill the invited address; it stays editable (the mailbox is + // verified separately either way). + const setValue = form.setValue; + useEffect(() => { + if (invitation.data) setValue('email', invitation.data.email); + }, [invitation.data, setValue]); + const onSubmit = form.handleSubmit(async (input) => { setError(null); try { - await apiPost('/auth/signup', input); + // Only a previewed-valid token rides along; a broken link falls + // back to a plain signup instead of failing the whole form. + await apiPost('/auth/signup', { + ...input, + invitationToken: invited ? invitationToken : undefined, + }); setRegistered(input.email); } catch (err) { setError(err); @@ -42,10 +67,25 @@ export function SignupPage(): React.JSX.Element { } }); - if (registration.data?.mode === 'closed') { + // With a pending invitation check, neither the closed screen nor the + // form should flash — hold the decision until the preview settles. + if (registration.data?.mode === 'closed' && invitationToken && invitation.isPending) { return (

{t('auth:signup.title')}

+
+ ); + } + + if (registration.data?.mode === 'closed' && !invited) { + return ( +
+

{t('auth:signup.title')}

+ {invitationToken && invitation.isError && ( +

+ {t('invitations:signup.invalid')} +

+ )}

{t('auth:signup.closed')}

{t('auth:signup.loginLink')} @@ -74,6 +114,19 @@ export function SignupPage(): React.JSX.Element {

{t('auth:signup.title')}

+ {invited && invitation.data && ( +

+ {t('invitations:signup.banner', { + inviterName: invitation.data.inviterName, + email: invitation.data.email, + })} +

+ )} + {invitationToken && invitation.isError && ( +

+ {t('invitations:signup.invalid')} +

+ )} ; /** Form-side type: locale is optional before Zod applies its default. */ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 27d8161..975af81 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -18,6 +18,7 @@ export * from './fonts'; export * from './health'; export * from './home'; export * from './i18n-tools'; +export * from './invitations'; export * from './labels'; export * from './legal'; export * from './links'; diff --git a/packages/shared/src/invitations.ts b/packages/shared/src/invitations.ts new file mode 100644 index 0000000..9d36dde --- /dev/null +++ b/packages/shared/src/invitations.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; + +/** + * Peer invitations (issue #332): a user invites an e-mail address; the + * mailed token allows exactly one registration even while registration + * is closed. A per-user quota on OPEN (pending, unexpired) invitations — + * the instance setting `invitations.maxOpenPerUser`, default 5, 0 turns + * the feature off — keeps the feature from becoming a spam channel. + */ + +export type InvitationStatus = 'pending' | 'accepted' | 'revoked' | 'expired'; + +export interface InvitationView { + id: string; + email: string; + status: InvitationStatus; + createdAt: string; + expiresAt: string; +} + +export interface InvitationListView { + invitations: InvitationView[]; + /** Open (pending, unexpired) invitations counted against the quota. */ + open: number; + /** The instance-wide per-user quota; 0 = inviting disabled. */ + maxOpen: number; +} + +export const createInvitationSchema = z.object({ + email: z.string().email('validation.email.invalid').max(254), +}); +export type CreateInvitationInput = z.infer; + +export const invitationPreviewSchema = z.object({ token: z.string().min(16).max(256) }); + +/** What the signup screen shows about an invitation link before use. */ +export interface InvitationPreview { + email: string; + inviterName: string; +} diff --git a/packages/shared/src/vs-nfd-profile.ts b/packages/shared/src/vs-nfd-profile.ts index 74af0a4..eca9b3c 100644 --- a/packages/shared/src/vs-nfd-profile.ts +++ b/packages/shared/src/vs-nfd-profile.ts @@ -59,6 +59,14 @@ export const VS_NFD_PROFILE: readonly VsNfdProfileEntry[] = [ compliance: { kind: 'equals', value: 'closed' }, hardeningRef: '1.1', }, + { + scope: 'instance', + // Invitations (issue #332) open a controlled signup path even while + // registration is closed — in the reference profile they stay off. + key: 'invitations.maxOpenPerUser', + compliance: { kind: 'maxNumber', value: 0 }, + hardeningRef: '1.1', + }, { scope: 'instance', key: 'api.enabled',