Compare commits

...

2 Commits

Author SHA1 Message Date
7e17a2dba6 settings-nav fence: 10 sections since the invitations section (#332)
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m52s
CI / Auth e2e pack (pull_request) Successful in 9m36s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CI / Build container images (pull_request) Successful in 1m14s
CD / Build and push images (push) Successful in 16s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m26s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 7m0s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 9m19s
CI / Import/export fidelity gate (push) Successful in 59s
Release / Build release images and notes (push) Successful in 2m54s
Release / Release-candidate operations QA (push) Successful in 49s
Prod deploy / Deploy the released images to Prod (push) Successful in 19s
2026-08-05 13:06:32 +02:00
c2a4dde5cc Invitation flow with per-user quota (#332)
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m50s
CI / Build container images (pull_request) Successful in 3m57s
CI / Auth e2e pack (pull_request) Failing after 6m18s
CI / Import/export fidelity gate (pull_request) Has been skipped
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
2026-08-05 12:44:20 +02:00
35 changed files with 1095 additions and 15 deletions

View File

@ -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%';" | \

View File

@ -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;

View File

@ -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

View File

@ -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' },

View File

@ -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,

View File

@ -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 });
}

View 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);
}
}

View 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);
});
});

View 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 {}

View 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');
}

View File

@ -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');

View File

@ -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);

View File

@ -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

View File

@ -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

View 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();
}
});

View File

@ -28,8 +28,9 @@ test('user settings show the jump nav and clicking scrolls + activates', async (
await expect(nav).toBeVisible();
const links = nav.locator('.settings-nav__link');
// Profile, password, sessions, watches, API tokens, feed tokens, data export.
// 8 seit #170 (Bedienung), 9 seit #180 (Erscheinungsbild).
await expect(links).toHaveCount(9);
// 8 seit #170 (Bedienung), 9 seit #180 (Erscheinungsbild),
// 10 seit #332 (Einladungen).
await expect(links).toHaveCount(10);
// Jump to the last section: it scrolls into view and becomes active.
const last = links.last();

View File

@ -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,

View File

@ -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')}

View 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>
);
}

View File

@ -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 />

View File

@ -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,

View File

@ -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;

View File

@ -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')}

View File

@ -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 |

View File

@ -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. |

View File

@ -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.",

View 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."
}
}

View File

@ -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:",

View File

@ -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.",

View 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."
}
}

View File

@ -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:",

View File

@ -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. */

View File

@ -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';

View 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;
}

View File

@ -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',