feat(backend): local accounts + invites for Gemeinde Teamer

Per the updated plan, Gemeinde Teamer are no longer Authentik-backed; they
are local accounts a Gemeinde Verantwortliche/r provisions per KC.

Schema:
- User.authentikSub now nullable; add passwordHash + kcId (cascade from Kc)
  so one User model covers Authentik members and local Teamer.
- new TeamerInvite model: shareable group link (email null, maxUses null)
  or personal invite (email pinned, single use), with expiry + revoke.
- sync log now also replicates User / Membership / TeamerInvite.

Auth:
- TeamAuthService: bcrypt password login (POST /auth/team-login) and invite
  redemption (POST /auth/teamer/register) issuing a JWT signed with
  TEAM_JWT_SECRET, payload typ:"team".
- TeamJwtStrategy (AuthGuard('team')) resolves it to the same
  AuthenticatedUser shape as AuthentikStrategy.
- TokenVerificationService.verifyEither() also accepts team tokens (WS).
- files + chat read endpoints accept 'team' tokens; Teamer see non-Konfi
  files and can use chat / start DMs.

Teamer admin (teamer/ module, under /gemeinde/:gemeindeId):
- POST/GET teamer, DELETE teamer/:userId
- POST/GET teamer-invites, DELETE teamer-invites/:inviteId
- LT may manage any Gemeinde; a Verantwortliche/r only their own
  (checked in TeamerService, since RolesGuard only scopes by kcId).

Tests: TeamAuthService + TeamerService specs added (Prisma/Sync mocked),
npm test green at 32. Docs (plan + backend README) updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 16:50:06 +02:00
co-authored by Claude Sonnet 5
parent 081a9aa241
commit d48c07b0e4
24 changed files with 1076 additions and 30 deletions
+160
View File
@@ -0,0 +1,160 @@
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';
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');
}
async login(email: string, password: string): Promise<{ accessToken: string }> {
const user = await this.prisma.user.findUnique({
where: { email: email.toLowerCase() },
include: { memberships: true },
});
if (!user || !user.passwordHash) {
throw new UnauthorizedException('Invalid credentials');
}
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) {
throw new UnauthorizedException('Invalid credentials');
}
return { accessToken: this.sign(user.id) };
}
/// 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: true },
});
if (!user) {
throw new UnauthorizedException('Team account no longer exists');
}
return {
userId: user.id,
authentikSub: user.authentikSub,
email: user.email,
memberships: user.memberships.map((m) => ({
kcId: m.kcId,
gemeindeId: m.gemeindeId,
role: m.role,
})),
};
}
}