Invitation flow with per-user quota (#332)
Any authenticated user can invite an e-mail address; the mailed single-use token lets exactly one signup through even while registration is closed. Open (pending, unexpired) invitations count against the new instance setting invitations.maxOpenPerUser (default 5, 0 disables inviting) — plus a 20/day per-user rate limit so a revoke-and-recreate loop cannot become a mail cannon. Only the SHA-256 token hash is stored (auth-tokens pattern); a failed signup (taken username) un-redeems the token so the invitee can retry. Surfaces: invitations section in the user settings (list, invite, revoke, quota line; wide table in a focusable .table-scroll region), signup page reads ?invitation=<token> (preview banner, e-mail prefill, closed-mode gate opens only for a previewed-valid token), admin general card gets the quota field (flat RHF name per #322; VS-NfD marked and hideable). Governance: audit actions invitation.created/revoked/accepted (catalogue 1.10), VS-NfD profile entry (compliant: 0) + hardening-guide row, i18n de+en including the invitation mail template. Tests: api e2e-db (mail link, closed-mode single-use signup with un-redeem on failure, quota + revoke frees slot, quota 0 = 403, auth matrix), new web e2e pack invitations.spec.ts (full UI loop through Mailpit, wired into ci.yml with its own rate-limit reset), a11y scan waits for the new section. Full api suite (107 files / 607 tests), auth/admin-settings/a11y packs green against a fresh local stack. Closes #332
This commit is contained in:
parent
9cf7b85b93
commit
c2a4dde5cc
@ -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%';" | \
|
||||
|
||||
@ -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;
|
||||
@ -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
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<void> {
|
||||
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 });
|
||||
}
|
||||
|
||||
50
apps/api/src/invitations/invitations.controller.ts
Normal file
50
apps/api/src/invitations/invitations.controller.ts
Normal file
@ -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<InvitationView> {
|
||||
return this.invitations.create(request.user!, input.email);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async list(@Req() request: AuthedRequest): Promise<InvitationListView> {
|
||||
return this.invitations.list(request.user!);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
async revoke(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
|
||||
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<InvitationPreview> {
|
||||
return this.invitations.preview(input.token);
|
||||
}
|
||||
}
|
||||
246
apps/api/src/invitations/invitations.e2e.db.test.ts
Normal file
246
apps/api/src/invitations/invitations.e2e.db.test.ts
Normal file
@ -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<string, string> = {};
|
||||
const cookies: Record<string, string> = {};
|
||||
|
||||
const api = () => request(app.getHttpServer());
|
||||
const settings = () => app.get(InstanceSettingsService);
|
||||
|
||||
async function makeUser(handle: string): Promise<void> {
|
||||
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<string> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
13
apps/api/src/invitations/invitations.module.ts
Normal file
13
apps/api/src/invitations/invitations.module.ts
Normal file
@ -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 {}
|
||||
199
apps/api/src/invitations/invitations.service.ts
Normal file
199
apps/api/src/invitations/invitations.service.ts
Normal file
@ -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<InvitationView> {
|
||||
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<InvitationListView> {
|
||||
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<void> {
|
||||
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<InvitationPreview> {
|
||||
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<Invitation | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.prisma.invitation.updateMany({
|
||||
where: { id, acceptedUserId: null },
|
||||
data: { acceptedAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
private openCount(inviterId: string): Promise<number> {
|
||||
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');
|
||||
}
|
||||
@ -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<string, string>,
|
||||
locale: 'de' | 'en',
|
||||
): RenderedMail {
|
||||
const t = (key: string, options: Record<string, string> = {}): 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');
|
||||
|
||||
@ -14,7 +14,7 @@ export class MailService {
|
||||
async enqueue(
|
||||
to: string,
|
||||
template: MailTemplate,
|
||||
params: { displayName: string; link: string },
|
||||
params: { displayName: string; link: string } & Record<string, string>,
|
||||
locale: 'de' | 'en',
|
||||
): Promise<void> {
|
||||
const rendered = renderMail(template, params, locale);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
93
apps/web/e2e/invitations.spec.ts
Normal file
93
apps/web/e2e/invitations.spec.ts
Normal file
@ -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();
|
||||
}
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
<section className="settings-section">
|
||||
<h2>{t('settings:admin.general')}</h2>
|
||||
{(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 {
|
||||
<option value="closed">{t('settings:admin.registrationClosed')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
{!vsNfd.hides(
|
||||
'invitations.maxOpenPerUser',
|
||||
settings.data['invitations.maxOpenPerUser'],
|
||||
) && (
|
||||
<Field
|
||||
label={t('settings:admin.invitationsMaxOpen')}
|
||||
hint={t('settings:admin.invitationsMaxOpenHelp')}
|
||||
marking={vsNfd.markingFor(
|
||||
'invitations.maxOpenPerUser',
|
||||
form.watch('invitationsMaxOpenPerUser'),
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
{...form.register('invitationsMaxOpenPerUser', { valueAsNumber: true })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field
|
||||
label={t('settings:admin.newPageClassification')}
|
||||
hint={t('settings:admin.newPageClassificationHelp')}
|
||||
|
||||
140
apps/web/src/pages/InvitationsSection.tsx
Normal file
140
apps/web/src/pages/InvitationsSection.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import type { InvitationListView } from '@dorfteich/shared';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Field, FormError } from '../components/forms';
|
||||
import { apiDelete, apiGet, apiPost } from '../lib/api';
|
||||
|
||||
const INVITATIONS_QUERY_KEY = ['users', 'me', 'invitations'] as const;
|
||||
|
||||
/**
|
||||
* Peer invitations in the user settings (issue #332): invite an e-mail
|
||||
* address, see your invitations with their status, revoke open ones. The
|
||||
* quota line shows how many of the instance-wide per-user allowance are
|
||||
* in use; with a quota of 0 the section explains that inviting is off.
|
||||
*/
|
||||
export function InvitationsSection(): React.JSX.Element {
|
||||
const { t } = useTranslation('invitations');
|
||||
const queryClient = useQueryClient();
|
||||
const [email, setEmail] = useState('');
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: INVITATIONS_QUERY_KEY,
|
||||
queryFn: () => apiGet<InvitationListView>('/invitations'),
|
||||
});
|
||||
|
||||
const submit = async (event: React.FormEvent): Promise<void> => {
|
||||
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<void> => {
|
||||
setError(null);
|
||||
await apiDelete(`/invitations/${id}`);
|
||||
await queryClient.invalidateQueries({ queryKey: INVITATIONS_QUERY_KEY });
|
||||
};
|
||||
|
||||
const data = list.data;
|
||||
const disabled = data?.maxOpen === 0;
|
||||
|
||||
return (
|
||||
<section className="settings-section invitations">
|
||||
<h2>{t('section.title')}</h2>
|
||||
{disabled ? (
|
||||
<p>{t('section.disabled')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="invitations__intro">{t('section.intro')}</p>
|
||||
{data && (
|
||||
<p className="invitations__quota">
|
||||
{t('section.quota', { open: data.open, max: data.maxOpen })}
|
||||
</p>
|
||||
)}
|
||||
<form onSubmit={(e) => void submit(e)} noValidate className="invitations__form">
|
||||
<FormError error={error} />
|
||||
{/* Scoped status region: a bare getByRole('status') must stay
|
||||
unambiguous for other specs (lesson from #304/legal). */}
|
||||
<p className="invitations__sent" role="status">
|
||||
{sent ? t('form.sent') : ''}
|
||||
</p>
|
||||
<Field label={t('form.email')}>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
required
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<button type="submit" className="button" disabled={busy || email.length === 0}>
|
||||
{t('form.submit')}
|
||||
</button>
|
||||
</form>
|
||||
{data && data.invitations.length === 0 && <p>{t('section.empty')}</p>}
|
||||
{data && data.invitations.length > 0 && (
|
||||
<div
|
||||
className="table-scroll"
|
||||
// A scroll container is only operable by keyboard once it is
|
||||
// focusable; role+name keep it from being an unlabelled stop.
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label={t('section.title')}
|
||||
>
|
||||
<table className="table invitations__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('columns.email')}</th>
|
||||
<th>{t('columns.status')}</th>
|
||||
<th>{t('columns.created')}</th>
|
||||
<th>{t('columns.expires')}</th>
|
||||
<th>{t('columns.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.invitations.map((invitation) => (
|
||||
<tr
|
||||
key={invitation.id}
|
||||
className="invitation-row"
|
||||
data-email={invitation.email}
|
||||
>
|
||||
<td>{invitation.email}</td>
|
||||
<td className="invitation-row__status">{t(`status.${invitation.status}`)}</td>
|
||||
<td>{new Date(invitation.createdAt).toLocaleDateString()}</td>
|
||||
<td>{new Date(invitation.expiresAt).toLocaleDateString()}</td>
|
||||
<td>
|
||||
{invitation.status === 'pending' && (
|
||||
<button
|
||||
type="button"
|
||||
className="linklike invitation-row__revoke"
|
||||
onClick={() => void revoke(invitation.id)}
|
||||
>
|
||||
{t('actions.revoke')}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -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 {
|
||||
<PasswordSection />
|
||||
<SessionsSection />
|
||||
<WatchesSection />
|
||||
<InvitationsSection />
|
||||
<ApiTokensSection />
|
||||
<FeedTokensSection />
|
||||
<AppearanceSection />
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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=<token>: 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<InvitationPreview>('/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 (
|
||||
<div className="auth-card">
|
||||
<h1>{t('auth:signup.title')}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (registration.data?.mode === 'closed' && !invited) {
|
||||
return (
|
||||
<div className="auth-card">
|
||||
<h1>{t('auth:signup.title')}</h1>
|
||||
{invitationToken && invitation.isError && (
|
||||
<p className="form-banner form-banner--error signup-invitation__invalid">
|
||||
{t('invitations:signup.invalid')}
|
||||
</p>
|
||||
)}
|
||||
<p className="form-banner">{t('auth:signup.closed')}</p>
|
||||
<p className="auth-card__links">
|
||||
<Link to="/login">{t('auth:signup.loginLink')}</Link>
|
||||
@ -74,6 +114,19 @@ export function SignupPage(): React.JSX.Element {
|
||||
<div className="auth-card">
|
||||
<h1>{t('auth:signup.title')}</h1>
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
{invited && invitation.data && (
|
||||
<p className="form-banner signup-invitation__banner">
|
||||
{t('invitations:signup.banner', {
|
||||
inviterName: invitation.data.inviterName,
|
||||
email: invitation.data.email,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{invitationToken && invitation.isError && (
|
||||
<p className="form-banner form-banner--error signup-invitation__invalid">
|
||||
{t('invitations:signup.invalid')}
|
||||
</p>
|
||||
)}
|
||||
<FormError error={error} />
|
||||
<Field
|
||||
label={t('auth:signup.username')}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
# Audit event catalogue
|
||||
|
||||
**Catalogue version 1.9 (2026-08-05; 1.9 adds `user.created_by_admin`,
|
||||
**Catalogue version 1.10 (2026-08-05; 1.10 adds `invitation.created`,
|
||||
`invitation.revoked` and `invitation.accepted`, issue #332;
|
||||
1.9 added `user.created_by_admin`,
|
||||
issue #331; 1.8 added `pond.archived`,
|
||||
issue #305; 1.7 added `branding.changed`,
|
||||
issue #306; 1.6 added `font.uploaded` and
|
||||
@ -78,6 +80,14 @@ failure), `warning` = feeds detection (suspicious or destructive),
|
||||
| `auth.identity_linked` | OIDC identity linked to an existing account via the explicit link flow (issue #214) | notice | the linking user | — | `provider` |
|
||||
| `auth.proxy_rejected` | Proxy-auth header received from a peer outside the allowlist — spoof attempt (issue #215) | warning | `null` (unauthenticated) | — | `peer`, `header` |
|
||||
|
||||
### Invitations (`invitation.*`, issue #332)
|
||||
|
||||
| Id | Trigger | Severity | Actor | Target | Fields |
|
||||
| --------------------- | ---------------------------------------------------------------- | -------- | ----------------- | ------------ | ------ |
|
||||
| `invitation.created` | User invites an e-mail address (mail with signup link sent) | info | the inviting user | `invitation` | — |
|
||||
| `invitation.revoked` | Open invitation withdrawn by its creator | info | the inviting user | `invitation` | — |
|
||||
| `invitation.accepted` | Signup completed through an invitation link (closed-mode bypass) | notice | the new user | `invitation` | — |
|
||||
|
||||
### Access & membership (`grant.*`, `member.*`)
|
||||
|
||||
| Id | Trigger | Severity | Actor | Target | Fields |
|
||||
|
||||
@ -29,6 +29,7 @@ Settings-Cache ist in-process (operations.md).
|
||||
| Setting | Referenzwert | Default | Warum |
|
||||
| ----------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `auth.registrationMode` | `closed` | `open` | Konten entstehen in einer VS-Umgebung nur kontrolliert; Selbstregistrierung öffnet den Nutzerkreis unkontrolliert. |
|
||||
| `invitations.maxOpenPerUser` | `0` | `5` | **explizit setzen** — Einladungen (#332) öffnen einen kontrollierten Registrierungsweg an `auth.registrationMode=closed` vorbei; in der Referenzkonfiguration bleibt der Nutzerkreis allein Sache des Betreibers (Konten legt der Site-Admin an, #331). `0` schaltet das Einladen ab (403 `invitations_disabled`). |
|
||||
| `api.enabled` | `false` | `false` | Public REST API ist ein zusätzlicher Egress-Kanal; ohne dokumentierten Bedarf bleibt er zu (404 auf allen `/api/public/v1`-Routen). |
|
||||
| `mcp.enabled` | `false` | `false` | gleiches Argument für den MCP-Endpoint (`/api/mcp`); unabhängiger Schalter. |
|
||||
| `feeds.enabled` | `false` | `true` | **explizit setzen** — Atom-Feeds liefern Inhalte an Reader außerhalb der Kontrolle der Instanz (Feed-Token umgehen die Session); Kopien in Feed-Readern sind nicht einholbar (Kopienliste, Sicherheitsdokumentation §5). Schaltet Routen UND Feed-Token-Verwaltung auf 404. |
|
||||
|
||||
@ -9,6 +9,9 @@
|
||||
"rate_limited": "Zu viele Anfragen — bitte versuche es später erneut.",
|
||||
"internal_error": "Interner Serverfehler.",
|
||||
"registration_closed": "Die Registrierung ist auf dieser Instanz derzeit geschlossen.",
|
||||
"invitations_disabled": "Einladungen sind auf dieser Instanz deaktiviert.",
|
||||
"invitation_quota_reached": "Du hast die Höchstzahl offener Einladungen erreicht. Widerrufe eine offene Einladung oder warte, bis eine angenommen wurde oder abgelaufen ist.",
|
||||
"invitation_already_accepted": "Diese Einladung wurde bereits angenommen und kann nicht mehr widerrufen werden.",
|
||||
"token_invalid": "Dieser Link ist ungültig oder abgelaufen.",
|
||||
"login_failed": "Benutzername/E-Mail oder Passwort ist falsch.",
|
||||
"login_backoff": "Zu viele Fehlversuche — bitte warte ein paar Minuten.",
|
||||
|
||||
34
packages/shared/i18n/de/invitations.json
Normal file
34
packages/shared/i18n/de/invitations.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"section": {
|
||||
"title": "Einladungen",
|
||||
"intro": "Lade Personen per E-Mail ein. Der Link erlaubt genau eine Registrierung — auch wenn die Selbst-Registrierung geschlossen ist — und ist 14 Tage gültig.",
|
||||
"quota": "{{open}} von {{max}} offenen Einladungen belegt.",
|
||||
"disabled": "Einladungen sind auf dieser Instanz deaktiviert.",
|
||||
"empty": "Noch keine Einladungen."
|
||||
},
|
||||
"form": {
|
||||
"email": "E-Mail-Adresse",
|
||||
"submit": "Einladen",
|
||||
"sent": "Einladung verschickt."
|
||||
},
|
||||
"columns": {
|
||||
"email": "E-Mail",
|
||||
"status": "Status",
|
||||
"created": "Eingeladen am",
|
||||
"expires": "Gültig bis",
|
||||
"actions": "Aktionen"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Offen",
|
||||
"accepted": "Angenommen",
|
||||
"revoked": "Widerrufen",
|
||||
"expired": "Abgelaufen"
|
||||
},
|
||||
"actions": {
|
||||
"revoke": "Widerrufen"
|
||||
},
|
||||
"signup": {
|
||||
"banner": "{{inviterName}} lädt dich ein ({{email}}). Mit dieser Einladung kannst du dir jetzt ein Konto anlegen.",
|
||||
"invalid": "Dieser Einladungslink ist ungültig, abgelaufen oder wurde bereits verwendet."
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,12 @@
|
||||
"action": "Neues Passwort setzen",
|
||||
"expiry": "Der Link ist eine Stunde gültig. Dein aktuelles Passwort bleibt gültig, bis du ein neues gesetzt hast."
|
||||
},
|
||||
"invitation": {
|
||||
"subject": "Du bist eingeladen: Dorfteich",
|
||||
"body": "{{inviterName}} lädt dich zu einem Dorfteich ein — einem gemeinsamen Ort für Seiten und Notizen. Über diesen Link kannst du dir ein Konto anlegen:",
|
||||
"action": "Einladung annehmen",
|
||||
"expiry": "Der Link ist 14 Tage gültig und kann nur einmal verwendet werden."
|
||||
},
|
||||
"smtpTest": {
|
||||
"subject": "SMTP-Testnachricht",
|
||||
"body": "diese Testnachricht bestätigt, dass dein Dorfteich E-Mails über den konfigurierten SMTP-Server versenden kann. Deine Instanz erreichst du hier:",
|
||||
|
||||
@ -9,6 +9,9 @@
|
||||
"rate_limited": "Too many requests — please try again later.",
|
||||
"internal_error": "Internal server error.",
|
||||
"registration_closed": "Registration is currently closed on this instance.",
|
||||
"invitations_disabled": "Invitations are disabled on this instance.",
|
||||
"invitation_quota_reached": "You have reached the maximum number of open invitations. Revoke an open invitation or wait until one is accepted or expires.",
|
||||
"invitation_already_accepted": "This invitation has already been accepted and can no longer be revoked.",
|
||||
"token_invalid": "This link is invalid or has expired.",
|
||||
"login_failed": "Username/e-mail or password is incorrect.",
|
||||
"login_backoff": "Too many failed attempts — please wait a few minutes.",
|
||||
|
||||
34
packages/shared/i18n/en/invitations.json
Normal file
34
packages/shared/i18n/en/invitations.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"section": {
|
||||
"title": "Invitations",
|
||||
"intro": "Invite people by e-mail. The link allows exactly one registration — even while self-registration is closed — and is valid for 14 days.",
|
||||
"quota": "{{open}} of {{max}} open invitations used.",
|
||||
"disabled": "Invitations are disabled on this instance.",
|
||||
"empty": "No invitations yet."
|
||||
},
|
||||
"form": {
|
||||
"email": "E-mail address",
|
||||
"submit": "Invite",
|
||||
"sent": "Invitation sent."
|
||||
},
|
||||
"columns": {
|
||||
"email": "E-mail",
|
||||
"status": "Status",
|
||||
"created": "Invited on",
|
||||
"expires": "Valid until",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Open",
|
||||
"accepted": "Accepted",
|
||||
"revoked": "Revoked",
|
||||
"expired": "Expired"
|
||||
},
|
||||
"actions": {
|
||||
"revoke": "Revoke"
|
||||
},
|
||||
"signup": {
|
||||
"banner": "{{inviterName}} invites you ({{email}}). With this invitation you can create an account now.",
|
||||
"invalid": "This invitation link is invalid, expired, or has already been used."
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,12 @@
|
||||
"action": "Set new password",
|
||||
"expiry": "The link is valid for one hour. Your current password stays valid until you set a new one."
|
||||
},
|
||||
"invitation": {
|
||||
"subject": "You are invited: Dorfteich",
|
||||
"body": "{{inviterName}} invites you to a Dorfteich - a shared place for pages and notes. Use this link to create your account:",
|
||||
"action": "Accept invitation",
|
||||
"expiry": "The link is valid for 14 days and can only be used once."
|
||||
},
|
||||
"smtpTest": {
|
||||
"subject": "SMTP test message",
|
||||
"body": "this test message confirms that your Dorfteich can send e-mail through the configured SMTP server. You can reach your instance here:",
|
||||
|
||||
@ -44,6 +44,9 @@ export const signupInputSchema = z.object({
|
||||
displayName: z.string().trim().min(1, 'validation.displayName.required').max(80),
|
||||
password: passwordSchema,
|
||||
locale: z.enum(['de', 'en']).default('en'),
|
||||
/** Invitation token (issue #332): lets this one signup through even
|
||||
* while registration is closed. */
|
||||
invitationToken: z.string().min(16).max(256).optional(),
|
||||
});
|
||||
export type SignupInput = z.infer<typeof signupInputSchema>;
|
||||
/** Form-side type: locale is optional before Zod applies its default. */
|
||||
|
||||
@ -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';
|
||||
|
||||
40
packages/shared/src/invitations.ts
Normal file
40
packages/shared/src/invitations.ts
Normal file
@ -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<typeof createInvitationSchema>;
|
||||
|
||||
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;
|
||||
}
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user