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
200 lines
6.8 KiB
TypeScript
200 lines
6.8 KiB
TypeScript
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');
|
|
}
|