feat(backend): Wahl-Phasen, Verantwortliche-Invites, Auth fixes
- New VerantwortlicheInvite model: LT-issued invites so a person can register as Gemeinde Verantwortliche(r) for a specific Gemeinde, skipping the self-registration approval step. - Wahl/Workshop/Teilnehmer gain phase support (phasenAnzahl, beschreibung), mirroring the WP plugin's multi-phase elections. Teilnehmer unique constraint now scoped per phase. - Auth: team login + guest auth adjustments, spec coverage. - sync.service.ts: register VerantwortlicheInvite as a synced model. - wahl.service.ts: submitTeilnehmer updated for the new phase-scoped unique key. - client: login/home screen rework, new theme.dart, FCM web tweaks. - .gitignore: ignore .DS_Store. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,16 +5,15 @@ import {
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { MembershipStatus, Role, SyncOperation } from '@prisma/client';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
import { TokenVerificationService } from '../auth/token-verification.service';
|
||||
import { resolveOrProvisionAuthentikUser } from '../auth/provision-user';
|
||||
import { AuthenticatedUser } from '../auth/authenticated-request';
|
||||
|
||||
/// Self-service onboarding for Gemeinde Verantwortliche. The person signs in
|
||||
/// with their Konfi-Castle-ID (Authentik) and submits a KC invite code plus
|
||||
/// the Gemeinde they belong to; this provisions their local User (JIT) and a
|
||||
/// PENDING membership that a Leitungsteam member must approve before it grants
|
||||
/// any rights.
|
||||
/// Self-service onboarding for Gemeinde Verantwortliche, plus the
|
||||
/// Leitungsteam-initiated shortcut that skips the approval step entirely.
|
||||
@Injectable()
|
||||
export class OnboardingService {
|
||||
constructor(
|
||||
@@ -133,4 +132,145 @@ export class OnboardingService {
|
||||
) {
|
||||
return { membershipId, status, kcName, gemeindeName };
|
||||
}
|
||||
|
||||
// --- Leitungsteam-issued Verantwortliche invites ---
|
||||
// Skips the PENDING approval step: an LT member vouching for someone
|
||||
// directly is enough, unlike self-registration which needs review.
|
||||
|
||||
async createInvite(
|
||||
caller: AuthenticatedUser,
|
||||
gemeindeId: string,
|
||||
dto: { email?: string; maxUses?: number; expiresInHours?: number },
|
||||
) {
|
||||
if (!caller.memberships.some((m) => m.role === Role.LEITUNGSTEAM)) {
|
||||
throw new UnauthorizedException('Only Leitungsteam can issue this invite');
|
||||
}
|
||||
const gemeinde = await this.prisma.gemeinde.findUnique({ where: { id: gemeindeId } });
|
||||
if (!gemeinde) {
|
||||
throw new NotFoundException('Gemeinde not found');
|
||||
}
|
||||
const email = dto.email?.toLowerCase() ?? null;
|
||||
const maxUses = dto.maxUses ?? (email ? 1 : null);
|
||||
const expiresAt = dto.expiresInHours
|
||||
? new Date(Date.now() + dto.expiresInHours * 3600_000)
|
||||
: null;
|
||||
|
||||
const invite = await this.prisma.verantwortlicheInvite.create({
|
||||
data: {
|
||||
kcId: gemeinde.kcId,
|
||||
gemeindeId,
|
||||
token: randomBytes(24).toString('base64url'),
|
||||
email,
|
||||
maxUses,
|
||||
expiresAt,
|
||||
createdByUserId: caller.userId,
|
||||
},
|
||||
});
|
||||
await this.sync.capture('VerantwortlicheInvite', SyncOperation.CREATE, invite.id, invite);
|
||||
return invite;
|
||||
}
|
||||
|
||||
async listInvites(caller: AuthenticatedUser, gemeindeId: string) {
|
||||
if (!caller.memberships.some((m) => m.role === Role.LEITUNGSTEAM)) {
|
||||
throw new UnauthorizedException('Only Leitungsteam can view this');
|
||||
}
|
||||
return this.prisma.verantwortlicheInvite.findMany({
|
||||
where: { gemeindeId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async revokeInvite(caller: AuthenticatedUser, gemeindeId: string, inviteId: string) {
|
||||
if (!caller.memberships.some((m) => m.role === Role.LEITUNGSTEAM)) {
|
||||
throw new UnauthorizedException('Only Leitungsteam can revoke this');
|
||||
}
|
||||
const invite = await this.prisma.verantwortlicheInvite.findFirst({
|
||||
where: { id: inviteId, gemeindeId },
|
||||
});
|
||||
if (!invite) {
|
||||
throw new NotFoundException('Invite not found');
|
||||
}
|
||||
const updated = await this.prisma.verantwortlicheInvite.update({
|
||||
where: { id: inviteId },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
await this.sync.capture('VerantwortlicheInvite', SyncOperation.UPDATE, updated.id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Redeems an LT-issued invite: provisions/updates the caller's Authentik
|
||||
/// User and grants an immediately-ACTIVE GEMEINDE_VERANTWORTLICHER
|
||||
/// membership (no approval step, unlike self-registration).
|
||||
async redeemInvite(token: string | undefined, inviteToken: string) {
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('Missing Authentik bearer token');
|
||||
}
|
||||
const invite = await this.prisma.verantwortlicheInvite.findUnique({
|
||||
where: { token: inviteToken },
|
||||
});
|
||||
if (!invite || invite.revokedAt) {
|
||||
throw new NotFoundException('Unknown or revoked invite');
|
||||
}
|
||||
if (invite.expiresAt && invite.expiresAt.getTime() < Date.now()) {
|
||||
throw new BadRequestException('Invite has expired');
|
||||
}
|
||||
if (invite.maxUses !== null && invite.usedCount >= invite.maxUses) {
|
||||
throw new BadRequestException('Invite has already been used up');
|
||||
}
|
||||
|
||||
const { isLeitungsteam, ...claims } = await this.tokens.verifyAuthentikClaims(token);
|
||||
if (invite.email && invite.email !== claims.email.toLowerCase()) {
|
||||
throw new BadRequestException('This invite is pinned to a different account');
|
||||
}
|
||||
|
||||
const user = await resolveOrProvisionAuthentikUser(
|
||||
this.prisma,
|
||||
this.sync,
|
||||
claims,
|
||||
isLeitungsteam,
|
||||
);
|
||||
|
||||
const existing = await this.prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_kcId_gemeindeId: {
|
||||
userId: user.id,
|
||||
kcId: invite.kcId,
|
||||
gemeindeId: invite.gemeindeId,
|
||||
},
|
||||
},
|
||||
});
|
||||
const membership = existing
|
||||
? await this.prisma.membership.update({
|
||||
where: { id: existing.id },
|
||||
data: { status: MembershipStatus.ACTIVE, role: Role.GEMEINDE_VERANTWORTLICHER },
|
||||
})
|
||||
: await this.prisma.membership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kcId: invite.kcId,
|
||||
gemeindeId: invite.gemeindeId,
|
||||
role: Role.GEMEINDE_VERANTWORTLICHER,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
await this.sync.capture(
|
||||
'Membership',
|
||||
existing ? SyncOperation.UPDATE : SyncOperation.CREATE,
|
||||
membership.id,
|
||||
membership,
|
||||
);
|
||||
|
||||
const updatedInvite = await this.prisma.verantwortlicheInvite.update({
|
||||
where: { id: invite.id },
|
||||
data: { usedCount: { increment: 1 } },
|
||||
});
|
||||
await this.sync.capture(
|
||||
'VerantwortlicheInvite',
|
||||
SyncOperation.UPDATE,
|
||||
updatedInvite.id,
|
||||
updatedInvite,
|
||||
);
|
||||
|
||||
return { membershipId: membership.id, status: membership.status };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user