- 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>
180 lines
6.2 KiB
TypeScript
180 lines
6.2 KiB
TypeScript
import {
|
|
ConflictException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
NotFoundException,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { Role, SyncOperation } from '@prisma/client';
|
|
import * as bcrypt from 'bcryptjs';
|
|
import * as jwt from 'jsonwebtoken';
|
|
import { PrismaClient } from '../prisma/prisma.module';
|
|
import { SyncService } from '../sync/sync.service';
|
|
import { AuthenticatedUser } from './authenticated-request';
|
|
import { toAuthenticatedUser } from './provision-user';
|
|
|
|
export interface TeamJwtPayload {
|
|
sub: string;
|
|
typ: 'team';
|
|
}
|
|
|
|
const TOKEN_TTL = '12h';
|
|
const BCRYPT_ROUNDS = 10;
|
|
|
|
/// Local (non-Authentik) auth for Gemeinde Teamer: password login plus
|
|
/// redemption of a TeamerInvite issued by a Gemeinde Verantwortliche/r. Team
|
|
/// tokens are signed with TEAM_JWT_SECRET and carry `typ: 'team'` so they are
|
|
/// never mistaken for a guest token.
|
|
@Injectable()
|
|
export class TeamAuthService {
|
|
private readonly secret: string;
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaClient,
|
|
private readonly config: ConfigService,
|
|
private readonly sync: SyncService,
|
|
) {
|
|
this.secret = config.getOrThrow<string>('TEAM_JWT_SECRET');
|
|
}
|
|
|
|
/// Logs a Teamer in by email (legacy) OR by Gemeinde name — the normal
|
|
/// path, since a Teamer thinks of their login as "meine Gemeinde" rather
|
|
/// than an email address. A Gemeinde can have several Teamer accounts, so
|
|
/// a name lookup tries the password against every active GEMEINDE_TEAMER
|
|
/// membership for that Gemeinde (case-insensitive, trimmed name) until one
|
|
/// matches, rather than assuming a 1:1 Gemeinde-to-account mapping.
|
|
async login(
|
|
credentials: { email?: string; gemeindeName?: string },
|
|
password: string,
|
|
): Promise<{ accessToken: string }> {
|
|
if (credentials.email) {
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { email: credentials.email.toLowerCase() },
|
|
});
|
|
if (!user || !user.passwordHash || !(await bcrypt.compare(password, user.passwordHash))) {
|
|
throw new UnauthorizedException('Invalid credentials');
|
|
}
|
|
return { accessToken: this.sign(user.id) };
|
|
}
|
|
|
|
const gemeindeName = credentials.gemeindeName?.trim();
|
|
if (!gemeindeName) {
|
|
throw new UnauthorizedException('Invalid credentials');
|
|
}
|
|
const memberships = await this.prisma.membership.findMany({
|
|
where: {
|
|
role: Role.GEMEINDE_TEAMER,
|
|
status: 'ACTIVE',
|
|
gemeinde: { name: { equals: gemeindeName, mode: 'insensitive' } },
|
|
},
|
|
include: { user: true },
|
|
});
|
|
for (const m of memberships) {
|
|
if (m.user.passwordHash && (await bcrypt.compare(password, m.user.passwordHash))) {
|
|
return { accessToken: this.sign(m.user.id) };
|
|
}
|
|
}
|
|
throw new UnauthorizedException('Invalid credentials');
|
|
}
|
|
|
|
/// Redeems an invite token and creates the local Teamer account + its
|
|
/// GEMEINDE_TEAMER membership for the invite's Gemeinde.
|
|
async registerFromInvite(input: {
|
|
token: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
password: string;
|
|
email?: string;
|
|
}): Promise<{ accessToken: string }> {
|
|
const invite = await this.prisma.teamerInvite.findUnique({
|
|
where: { token: input.token },
|
|
});
|
|
if (!invite || invite.revokedAt) {
|
|
throw new NotFoundException('Unknown or revoked invite');
|
|
}
|
|
if (invite.expiresAt && invite.expiresAt.getTime() < Date.now()) {
|
|
throw new ForbiddenException('Invite has expired');
|
|
}
|
|
if (invite.maxUses !== null && invite.usedCount >= invite.maxUses) {
|
|
throw new ForbiddenException('Invite has already been used up');
|
|
}
|
|
|
|
if (
|
|
invite.email &&
|
|
input.email &&
|
|
input.email.toLowerCase() !== invite.email.toLowerCase()
|
|
) {
|
|
throw new ForbiddenException('Email does not match this invite');
|
|
}
|
|
const email = (invite.email ?? input.email ?? '').toLowerCase();
|
|
if (!email) {
|
|
throw new ConflictException('This invite requires an email address');
|
|
}
|
|
if (await this.prisma.user.findUnique({ where: { email } })) {
|
|
throw new ConflictException('An account with this email already exists');
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(input.password, BCRYPT_ROUNDS);
|
|
const user = await this.prisma.user.create({
|
|
data: {
|
|
email,
|
|
firstName: input.firstName,
|
|
lastName: input.lastName,
|
|
passwordHash,
|
|
kcId: invite.kcId,
|
|
},
|
|
});
|
|
const membership = await this.prisma.membership.create({
|
|
data: {
|
|
userId: user.id,
|
|
kcId: invite.kcId,
|
|
gemeindeId: invite.gemeindeId,
|
|
role: Role.GEMEINDE_TEAMER,
|
|
},
|
|
});
|
|
const updatedInvite = await this.prisma.teamerInvite.update({
|
|
where: { id: invite.id },
|
|
data: { usedCount: { increment: 1 } },
|
|
});
|
|
|
|
await this.sync.capture('User', SyncOperation.CREATE, user.id, user);
|
|
await this.sync.capture('Membership', SyncOperation.CREATE, membership.id, membership);
|
|
await this.sync.capture('TeamerInvite', SyncOperation.UPDATE, updatedInvite.id, updatedInvite);
|
|
|
|
return { accessToken: this.sign(user.id) };
|
|
}
|
|
|
|
private sign(userId: string): string {
|
|
const payload: TeamJwtPayload = { sub: userId, typ: 'team' };
|
|
return jwt.sign(payload, this.secret, { expiresIn: TOKEN_TTL });
|
|
}
|
|
|
|
/// Verifies a raw team token (used by the WS handshake path, outside passport).
|
|
async verify(token: string): Promise<AuthenticatedUser> {
|
|
let payload: TeamJwtPayload;
|
|
try {
|
|
payload = jwt.verify(token, this.secret) as TeamJwtPayload;
|
|
} catch {
|
|
throw new UnauthorizedException('Invalid team token');
|
|
}
|
|
if (payload.typ !== 'team' || !payload.sub) {
|
|
throw new UnauthorizedException('Not a team token');
|
|
}
|
|
return this.resolve(payload.sub);
|
|
}
|
|
|
|
async resolve(userId: string): Promise<AuthenticatedUser> {
|
|
const user = await this.prisma.user.findFirst({
|
|
where: { id: userId, passwordHash: { not: null } },
|
|
include: { memberships: { where: { status: 'ACTIVE' } } },
|
|
});
|
|
if (!user) {
|
|
throw new UnauthorizedException('Team account no longer exists');
|
|
}
|
|
// Same shape as the Authentik path, incl. the synthetic global
|
|
// LEITUNGSTEAM membership when `isLeitungsteam` is set on the row.
|
|
return toAuthenticatedUser(user);
|
|
}
|
|
}
|