Compare commits
2 Commits
9cf7b85b93
...
7e17a2dba6
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e17a2dba6 | |||
| c2a4dde5cc |
@ -375,6 +375,18 @@ jobs:
|
|||||||
E2E_BASE_URL=http://localhost:5173 \
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/admin-users.spec.ts
|
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
|
- name: Reset login rate limit before permission-matrix pack
|
||||||
run: |
|
run: |
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
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[]
|
notifications Notification[]
|
||||||
favorites PageFavorite[]
|
favorites PageFavorite[]
|
||||||
customFonts CustomFont[]
|
customFonts CustomFont[]
|
||||||
|
invitations Invitation[] @relation("InvitationsSent")
|
||||||
|
|
||||||
@@map("users")
|
@@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
|
/// Persistent audit trail (issue #86, security.md §Logging): auth events and
|
||||||
/// admin actions — grants, member roles, plugin installs, quota and settings
|
/// admin actions — grants, member roles, plugin installs, quota and settings
|
||||||
/// changes, setup steps, manual job triggers. Written by AuditService, which
|
/// changes, setup steps, manual job triggers. Written by AuditService, which
|
||||||
|
|||||||
@ -29,6 +29,9 @@ export const AUDIT_EVENTS = {
|
|||||||
'file.integrity_failed': { severity: 'critical' },
|
'file.integrity_failed': { severity: 'critical' },
|
||||||
'grant.created': { severity: 'notice' },
|
'grant.created': { severity: 'notice' },
|
||||||
'grant.deleted': { severity: 'notice' },
|
'grant.deleted': { severity: 'notice' },
|
||||||
|
'invitation.accepted': { severity: 'notice' },
|
||||||
|
'invitation.created': { severity: 'info' },
|
||||||
|
'invitation.revoked': { severity: 'info' },
|
||||||
'job.triggered': { severity: 'info' },
|
'job.triggered': { severity: 'info' },
|
||||||
'member.added': { severity: 'notice' },
|
'member.added': { severity: 'notice' },
|
||||||
'member.removed': { severity: 'notice' },
|
'member.removed': { severity: 'notice' },
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { APP_GUARD } from '@nestjs/core';
|
|||||||
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
import { AppConfig } from '../config/app-config.service';
|
||||||
import { GrantsModule } from '../grants/grants.module';
|
import { GrantsModule } from '../grants/grants.module';
|
||||||
|
import { InvitationsModule } from '../invitations/invitations.module';
|
||||||
|
|
||||||
import { MailModule } from '../mail/mail.module';
|
import { MailModule } from '../mail/mail.module';
|
||||||
import { PondsModule } from '../ponds/ponds.module';
|
import { PondsModule } from '../ponds/ponds.module';
|
||||||
@ -18,7 +19,7 @@ import { ProxyIdentityService } from './proxy-identity.service';
|
|||||||
import { SessionsModule } from './sessions.module';
|
import { SessionsModule } from './sessions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule],
|
imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule, InvitationsModule],
|
||||||
controllers: [AuthController, OidcController],
|
controllers: [AuthController, OidcController],
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { User } from '@prisma/client';
|
|||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
import { AppConfig } from '../config/app-config.service';
|
||||||
|
import { InvitationsService } from '../invitations/invitations.service';
|
||||||
import { MailService } from '../mail/mail.service';
|
import { MailService } from '../mail/mail.service';
|
||||||
import { PondsService } from '../ponds/ponds.service';
|
import { PondsService } from '../ponds/ponds.service';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
@ -33,6 +34,7 @@ export class AuthService {
|
|||||||
private readonly sessions: SessionsService,
|
private readonly sessions: SessionsService,
|
||||||
private readonly mail: MailService,
|
private readonly mail: MailService,
|
||||||
private readonly ponds: PondsService,
|
private readonly ponds: PondsService,
|
||||||
|
private readonly invitations: InvitationsService,
|
||||||
private readonly rateLimits: RateLimitService,
|
private readonly rateLimits: RateLimitService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
private readonly config: AppConfig,
|
private readonly config: AppConfig,
|
||||||
@ -43,10 +45,37 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async signup(input: SignupInput): Promise<void> {
|
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' });
|
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.sendVerificationMail(user);
|
||||||
await this.audit.record({ action: 'auth.signup', actorId: user.id });
|
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';
|
import { apiI18n } from '../i18n/api-i18n';
|
||||||
|
|
||||||
export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest';
|
export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest' | 'invitation';
|
||||||
|
|
||||||
export interface RenderedMail {
|
export interface RenderedMail {
|
||||||
subject: string;
|
subject: string;
|
||||||
@ -15,14 +15,15 @@ export interface RenderedMail {
|
|||||||
*/
|
*/
|
||||||
export function renderMail(
|
export function renderMail(
|
||||||
template: MailTemplate,
|
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',
|
locale: 'de' | 'en',
|
||||||
): RenderedMail {
|
): RenderedMail {
|
||||||
const t = (key: string, options: Record<string, string> = {}): string =>
|
const t = (key: string, options: Record<string, string> = {}): string =>
|
||||||
apiI18n.t(`mails:${key}`, { lng: locale, ...options });
|
apiI18n.t(`mails:${key}`, { lng: locale, ...options });
|
||||||
|
|
||||||
const greeting = t('common.greeting', { displayName: params.displayName });
|
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 action = t(`${template}.action`);
|
||||||
const expiry = t(`${template}.expiry`);
|
const expiry = t(`${template}.expiry`);
|
||||||
const ignore = t('common.ignoreHint');
|
const ignore = t('common.ignoreHint');
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export class MailService {
|
|||||||
async enqueue(
|
async enqueue(
|
||||||
to: string,
|
to: string,
|
||||||
template: MailTemplate,
|
template: MailTemplate,
|
||||||
params: { displayName: string; link: string },
|
params: { displayName: string; link: string } & Record<string, string>,
|
||||||
locale: 'de' | 'en',
|
locale: 'de' | 'en',
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const rendered = renderMail(template, params, locale);
|
const rendered = renderMail(template, params, locale);
|
||||||
|
|||||||
@ -21,6 +21,9 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
*/
|
*/
|
||||||
export const INSTANCE_SETTINGS = {
|
export const INSTANCE_SETTINGS = {
|
||||||
'auth.registrationMode': z.enum(['open', 'closed']).default('open'),
|
'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.name': z.string().trim().min(1).max(60).default('Dorfteich'),
|
||||||
'instance.defaultLocale': z.enum(['de', 'en']).default('en'),
|
'instance.defaultLocale': z.enum(['de', 'en']).default('en'),
|
||||||
// Branding assets (issue #306). Metadata only — the PNG bytes live under
|
// 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.emulateMedia({ colorScheme: scheme });
|
||||||
await page.goto('/settings');
|
await page.goto('/settings');
|
||||||
await page.waitForLoadState('networkidle');
|
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})`);
|
await expectClean(page, `/settings (${scheme})`);
|
||||||
|
|
||||||
// Lizenzseite im selben Kontext (issue #304: sie trägt seit den
|
// 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -28,8 +28,9 @@ test('user settings show the jump nav and clicking scrolls + activates', async (
|
|||||||
await expect(nav).toBeVisible();
|
await expect(nav).toBeVisible();
|
||||||
const links = nav.locator('.settings-nav__link');
|
const links = nav.locator('.settings-nav__link');
|
||||||
// Profile, password, sessions, watches, API tokens, feed tokens, data export.
|
// Profile, password, sessions, watches, API tokens, feed tokens, data export.
|
||||||
// 8 seit #170 (Bedienung), 9 seit #180 (Erscheinungsbild).
|
// 8 seit #170 (Bedienung), 9 seit #180 (Erscheinungsbild),
|
||||||
await expect(links).toHaveCount(9);
|
// 10 seit #332 (Einladungen).
|
||||||
|
await expect(links).toHaveCount(10);
|
||||||
|
|
||||||
// Jump to the last section: it scrolls into view and becomes active.
|
// Jump to the last section: it scrolls into view and becomes active.
|
||||||
const last = links.last();
|
const last = links.last();
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import deFiles from '@dorfteich/shared/i18n/de/files.json';
|
|||||||
import deFont from '@dorfteich/shared/i18n/de/font.json';
|
import deFont from '@dorfteich/shared/i18n/de/font.json';
|
||||||
import deGraph from '@dorfteich/shared/i18n/de/graph.json';
|
import deGraph from '@dorfteich/shared/i18n/de/graph.json';
|
||||||
import deImport from '@dorfteich/shared/i18n/de/import.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 deLabels from '@dorfteich/shared/i18n/de/labels.json';
|
||||||
import deLegal from '@dorfteich/shared/i18n/de/legal.json';
|
import deLegal from '@dorfteich/shared/i18n/de/legal.json';
|
||||||
import deLinks from '@dorfteich/shared/i18n/de/links.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 enFont from '@dorfteich/shared/i18n/en/font.json';
|
||||||
import enGraph from '@dorfteich/shared/i18n/en/graph.json';
|
import enGraph from '@dorfteich/shared/i18n/en/graph.json';
|
||||||
import enImport from '@dorfteich/shared/i18n/en/import.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 enLabels from '@dorfteich/shared/i18n/en/labels.json';
|
||||||
import enLegal from '@dorfteich/shared/i18n/en/legal.json';
|
import enLegal from '@dorfteich/shared/i18n/en/legal.json';
|
||||||
import enLinks from '@dorfteich/shared/i18n/en/links.json';
|
import enLinks from '@dorfteich/shared/i18n/en/links.json';
|
||||||
@ -85,6 +87,7 @@ void i18n
|
|||||||
font: enFont,
|
font: enFont,
|
||||||
graph: enGraph,
|
graph: enGraph,
|
||||||
import: enImport,
|
import: enImport,
|
||||||
|
invitations: enInvitations,
|
||||||
labels: enLabels,
|
labels: enLabels,
|
||||||
legal: enLegal,
|
legal: enLegal,
|
||||||
links: enLinks,
|
links: enLinks,
|
||||||
@ -116,6 +119,7 @@ void i18n
|
|||||||
font: deFont,
|
font: deFont,
|
||||||
graph: deGraph,
|
graph: deGraph,
|
||||||
import: deImport,
|
import: deImport,
|
||||||
|
invitations: deInvitations,
|
||||||
labels: deLabels,
|
labels: deLabels,
|
||||||
legal: deLegal,
|
legal: deLegal,
|
||||||
links: deLinks,
|
links: deLinks,
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import { UserManager } from './UserManager';
|
|||||||
import { useDocumentTitle } from '../lib/use-document-title';
|
import { useDocumentTitle } from '../lib/use-document-title';
|
||||||
interface InstanceSettings {
|
interface InstanceSettings {
|
||||||
'auth.registrationMode': 'open' | 'closed';
|
'auth.registrationMode': 'open' | 'closed';
|
||||||
|
'invitations.maxOpenPerUser': number;
|
||||||
'instance.name': string;
|
'instance.name': string;
|
||||||
'instance.defaultLocale': 'de' | 'en';
|
'instance.defaultLocale': 'de' | 'en';
|
||||||
'quota.editorsPerPond': number;
|
'quota.editorsPerPond': number;
|
||||||
@ -94,6 +95,10 @@ export function AdminSettingsPage(): React.JSX.Element {
|
|||||||
<section className="settings-section">
|
<section className="settings-section">
|
||||||
<h2>{t('settings:admin.general')}</h2>
|
<h2>{t('settings:admin.general')}</h2>
|
||||||
{(vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) ||
|
{(vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) ||
|
||||||
|
vsNfd.hides(
|
||||||
|
'invitations.maxOpenPerUser',
|
||||||
|
settings.data['invitations.maxOpenPerUser'],
|
||||||
|
) ||
|
||||||
vsNfd.hides(
|
vsNfd.hides(
|
||||||
'classification.newPageDefault',
|
'classification.newPageDefault',
|
||||||
settings.data['classification.newPageDefault'],
|
settings.data['classification.newPageDefault'],
|
||||||
@ -125,6 +130,25 @@ export function AdminSettingsPage(): React.JSX.Element {
|
|||||||
<option value="closed">{t('settings:admin.registrationClosed')}</option>
|
<option value="closed">{t('settings:admin.registrationClosed')}</option>
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</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
|
<Field
|
||||||
label={t('settings:admin.newPageClassification')}
|
label={t('settings:admin.newPageClassification')}
|
||||||
hint={t('settings:admin.newPageClassificationHelp')}
|
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 { useDataExport } from '../export/use-data-export';
|
||||||
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
|
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
|
||||||
import { ApiTokensSection } from '../api-tokens/ApiTokensSection';
|
import { ApiTokensSection } from '../api-tokens/ApiTokensSection';
|
||||||
|
import { InvitationsSection } from './InvitationsSection';
|
||||||
import { FeedTokensSection } from '../api-tokens/FeedTokensSection';
|
import { FeedTokensSection } from '../api-tokens/FeedTokensSection';
|
||||||
import { WatchesSection } from '../watches/WatchesSection';
|
import { WatchesSection } from '../watches/WatchesSection';
|
||||||
|
|
||||||
@ -45,6 +46,7 @@ export function SettingsPage(): React.JSX.Element {
|
|||||||
<PasswordSection />
|
<PasswordSection />
|
||||||
<SessionsSection />
|
<SessionsSection />
|
||||||
<WatchesSection />
|
<WatchesSection />
|
||||||
|
<InvitationsSection />
|
||||||
<ApiTokensSection />
|
<ApiTokensSection />
|
||||||
<FeedTokensSection />
|
<FeedTokensSection />
|
||||||
<AppearanceSection />
|
<AppearanceSection />
|
||||||
|
|||||||
@ -22,6 +22,7 @@ describe('admin general settings form model (issue #322)', () => {
|
|||||||
'instance.name': 'My Wiki',
|
'instance.name': 'My Wiki',
|
||||||
'instance.defaultLocale': 'de',
|
'instance.defaultLocale': 'de',
|
||||||
'auth.registrationMode': 'closed',
|
'auth.registrationMode': 'closed',
|
||||||
|
'invitations.maxOpenPerUser': 5,
|
||||||
'classification.newPageDefault': 'unclassified',
|
'classification.newPageDefault': 'unclassified',
|
||||||
'classification.uploadPolicy': 'warn',
|
'classification.uploadPolicy': 'warn',
|
||||||
'quota.editorsPerPond': 5,
|
'quota.editorsPerPond': 5,
|
||||||
@ -38,6 +39,7 @@ describe('admin general settings form model (issue #322)', () => {
|
|||||||
instanceName: 'Renamed',
|
instanceName: 'Renamed',
|
||||||
defaultLocale: 'en',
|
defaultLocale: 'en',
|
||||||
registrationMode: 'open',
|
registrationMode: 'open',
|
||||||
|
invitationsMaxOpenPerUser: 5,
|
||||||
newPageClassification: 'vs_nfd',
|
newPageClassification: 'vs_nfd',
|
||||||
uploadPolicy: 'block',
|
uploadPolicy: 'block',
|
||||||
quotaEditorsPerPond: 1,
|
quotaEditorsPerPond: 1,
|
||||||
|
|||||||
@ -15,6 +15,7 @@ export const GENERAL_FORM_FIELDS = {
|
|||||||
instanceName: 'instance.name',
|
instanceName: 'instance.name',
|
||||||
defaultLocale: 'instance.defaultLocale',
|
defaultLocale: 'instance.defaultLocale',
|
||||||
registrationMode: 'auth.registrationMode',
|
registrationMode: 'auth.registrationMode',
|
||||||
|
invitationsMaxOpenPerUser: 'invitations.maxOpenPerUser',
|
||||||
newPageClassification: 'classification.newPageDefault',
|
newPageClassification: 'classification.newPageDefault',
|
||||||
uploadPolicy: 'classification.uploadPolicy',
|
uploadPolicy: 'classification.uploadPolicy',
|
||||||
quotaEditorsPerPond: 'quota.editorsPerPond',
|
quotaEditorsPerPond: 'quota.editorsPerPond',
|
||||||
@ -31,6 +32,7 @@ export interface GeneralSettingsForm {
|
|||||||
instanceName: string;
|
instanceName: string;
|
||||||
defaultLocale: 'de' | 'en';
|
defaultLocale: 'de' | 'en';
|
||||||
registrationMode: 'open' | 'closed';
|
registrationMode: 'open' | 'closed';
|
||||||
|
invitationsMaxOpenPerUser: number;
|
||||||
newPageClassification: 'unclassified' | 'vs_nfd';
|
newPageClassification: 'unclassified' | 'vs_nfd';
|
||||||
uploadPolicy: 'warn' | 'block';
|
uploadPolicy: 'warn' | 'block';
|
||||||
quotaEditorsPerPond: number;
|
quotaEditorsPerPond: number;
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
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 { useQuery } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Resolver, useForm } from 'react-hook-form';
|
import { Resolver, useForm } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { Field, FormError, applyFieldErrors } from '../../components/forms';
|
||||||
import { apiGet, apiPost } from '../../lib/api';
|
import { apiGet, apiPost } from '../../lib/api';
|
||||||
@ -21,6 +21,19 @@ export function SignupPage(): React.JSX.Element {
|
|||||||
queryFn: () => apiGet<{ mode: 'open' | 'closed' }>('/auth/registration'),
|
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;
|
type SignupFormValues = SignupFormInput;
|
||||||
// One confined cast: RHF cannot express Zod's input/output split
|
// One confined cast: RHF cannot express Zod's input/output split
|
||||||
// (locale is optional on input, defaulted on output) without it.
|
// (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' },
|
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) => {
|
const onSubmit = form.handleSubmit(async (input) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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);
|
setRegistered(input.email);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(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 (
|
return (
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
<h1>{t('auth:signup.title')}</h1>
|
<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="form-banner">{t('auth:signup.closed')}</p>
|
||||||
<p className="auth-card__links">
|
<p className="auth-card__links">
|
||||||
<Link to="/login">{t('auth:signup.loginLink')}</Link>
|
<Link to="/login">{t('auth:signup.loginLink')}</Link>
|
||||||
@ -74,6 +114,19 @@ export function SignupPage(): React.JSX.Element {
|
|||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
<h1>{t('auth:signup.title')}</h1>
|
<h1>{t('auth:signup.title')}</h1>
|
||||||
<form onSubmit={onSubmit} noValidate>
|
<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} />
|
<FormError error={error} />
|
||||||
<Field
|
<Field
|
||||||
label={t('auth:signup.username')}
|
label={t('auth:signup.username')}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
# Audit event catalogue
|
# 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 #331; 1.8 added `pond.archived`,
|
||||||
issue #305; 1.7 added `branding.changed`,
|
issue #305; 1.7 added `branding.changed`,
|
||||||
issue #306; 1.6 added `font.uploaded` and
|
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.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` |
|
| `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.*`)
|
### Access & membership (`grant.*`, `member.*`)
|
||||||
|
|
||||||
| Id | Trigger | Severity | Actor | Target | Fields |
|
| Id | Trigger | Severity | Actor | Target | Fields |
|
||||||
|
|||||||
@ -29,6 +29,7 @@ Settings-Cache ist in-process (operations.md).
|
|||||||
| Setting | Referenzwert | Default | Warum |
|
| Setting | Referenzwert | Default | Warum |
|
||||||
| ----------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `auth.registrationMode` | `closed` | `open` | Konten entstehen in einer VS-Umgebung nur kontrolliert; Selbstregistrierung öffnet den Nutzerkreis unkontrolliert. |
|
| `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). |
|
| `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. |
|
| `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. |
|
| `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.",
|
"rate_limited": "Zu viele Anfragen — bitte versuche es später erneut.",
|
||||||
"internal_error": "Interner Serverfehler.",
|
"internal_error": "Interner Serverfehler.",
|
||||||
"registration_closed": "Die Registrierung ist auf dieser Instanz derzeit geschlossen.",
|
"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.",
|
"token_invalid": "Dieser Link ist ungültig oder abgelaufen.",
|
||||||
"login_failed": "Benutzername/E-Mail oder Passwort ist falsch.",
|
"login_failed": "Benutzername/E-Mail oder Passwort ist falsch.",
|
||||||
"login_backoff": "Zu viele Fehlversuche — bitte warte ein paar Minuten.",
|
"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",
|
"action": "Neues Passwort setzen",
|
||||||
"expiry": "Der Link ist eine Stunde gültig. Dein aktuelles Passwort bleibt gültig, bis du ein neues gesetzt hast."
|
"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": {
|
"smtpTest": {
|
||||||
"subject": "SMTP-Testnachricht",
|
"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:",
|
"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.",
|
"rate_limited": "Too many requests — please try again later.",
|
||||||
"internal_error": "Internal server error.",
|
"internal_error": "Internal server error.",
|
||||||
"registration_closed": "Registration is currently closed on this instance.",
|
"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.",
|
"token_invalid": "This link is invalid or has expired.",
|
||||||
"login_failed": "Username/e-mail or password is incorrect.",
|
"login_failed": "Username/e-mail or password is incorrect.",
|
||||||
"login_backoff": "Too many failed attempts — please wait a few minutes.",
|
"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",
|
"action": "Set new password",
|
||||||
"expiry": "The link is valid for one hour. Your current password stays valid until you set a new one."
|
"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": {
|
"smtpTest": {
|
||||||
"subject": "SMTP test message",
|
"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:",
|
"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),
|
displayName: z.string().trim().min(1, 'validation.displayName.required').max(80),
|
||||||
password: passwordSchema,
|
password: passwordSchema,
|
||||||
locale: z.enum(['de', 'en']).default('en'),
|
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>;
|
export type SignupInput = z.infer<typeof signupInputSchema>;
|
||||||
/** Form-side type: locale is optional before Zod applies its default. */
|
/** Form-side type: locale is optional before Zod applies its default. */
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export * from './fonts';
|
|||||||
export * from './health';
|
export * from './health';
|
||||||
export * from './home';
|
export * from './home';
|
||||||
export * from './i18n-tools';
|
export * from './i18n-tools';
|
||||||
|
export * from './invitations';
|
||||||
export * from './labels';
|
export * from './labels';
|
||||||
export * from './legal';
|
export * from './legal';
|
||||||
export * from './links';
|
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' },
|
compliance: { kind: 'equals', value: 'closed' },
|
||||||
hardeningRef: '1.1',
|
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',
|
scope: 'instance',
|
||||||
key: 'api.enabled',
|
key: 'api.enabled',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user