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 39f8325287
commit 891105414a
25 changed files with 1091 additions and 37 deletions
@@ -0,0 +1,21 @@
import { IsEmail, IsInt, IsOptional, Min } from 'class-validator';
export class CreateTeamerInviteDto {
/// Set for a personal invite pinned to one address; omit for a shareable
/// group link.
@IsOptional()
@IsEmail()
email?: string;
/// Max redemptions. Defaults to 1 for a personal invite, unlimited for a
/// group link.
@IsOptional()
@IsInt()
@Min(1)
maxUses?: number;
@IsOptional()
@IsInt()
@Min(1)
expiresInHours?: number;
}
@@ -0,0 +1,18 @@
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
export class CreateTeamerDto {
@IsString()
@IsNotEmpty()
firstName!: string;
@IsString()
@IsNotEmpty()
lastName!: string;
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
}
+74
View File
@@ -0,0 +1,74 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { TeamerService } from './teamer.service';
import { CreateTeamerDto } from './dto/create-teamer.dto';
import { CreateTeamerInviteDto } from './dto/create-teamer-invite.dto';
import { Roles } from '../common/roles.decorator';
import { RolesGuard } from '../common/roles.guard';
import { Role } from '../common/role.enum';
import { AuthenticatedRequest } from '../auth/authenticated-request';
/// Gemeinde Teamer administration. The coarse guard admits Leitungsteam and
/// Gemeinde Verantwortliche (both Authentik-backed); TeamerService then
/// checks the caller is actually responsible for `:gemeindeId`.
@Controller('gemeinde/:gemeindeId')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@Roles(Role.LEITUNGSTEAM, Role.GEMEINDE_VERANTWORTLICHER)
export class TeamerController {
constructor(private readonly teamer: TeamerService) {}
@Post('teamer')
create(
@Param('gemeindeId') gemeindeId: string,
@Body() dto: CreateTeamerDto,
@Req() req: AuthenticatedRequest,
) {
return this.teamer.createTeamer(req.user!, gemeindeId, dto);
}
@Get('teamer')
list(@Param('gemeindeId') gemeindeId: string, @Req() req: AuthenticatedRequest) {
return this.teamer.listTeamer(req.user!, gemeindeId);
}
@Delete('teamer/:userId')
remove(
@Param('gemeindeId') gemeindeId: string,
@Param('userId') userId: string,
@Req() req: AuthenticatedRequest,
) {
return this.teamer.removeTeamer(req.user!, gemeindeId, userId);
}
@Post('teamer-invites')
createInvite(
@Param('gemeindeId') gemeindeId: string,
@Body() dto: CreateTeamerInviteDto,
@Req() req: AuthenticatedRequest,
) {
return this.teamer.createInvite(req.user!, gemeindeId, dto);
}
@Get('teamer-invites')
listInvites(@Param('gemeindeId') gemeindeId: string, @Req() req: AuthenticatedRequest) {
return this.teamer.listInvites(req.user!, gemeindeId);
}
@Delete('teamer-invites/:inviteId')
revokeInvite(
@Param('gemeindeId') gemeindeId: string,
@Param('inviteId') inviteId: string,
@Req() req: AuthenticatedRequest,
) {
return this.teamer.revokeInvite(req.user!, gemeindeId, inviteId);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TeamerService } from './teamer.service';
import { TeamerController } from './teamer.controller';
@Module({
providers: [TeamerService],
controllers: [TeamerController],
})
export class TeamerModule {}
+167
View File
@@ -0,0 +1,167 @@
import {
ConflictException,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { TeamerService } from './teamer.service';
import { AuthenticatedUser } from '../auth/authenticated-request';
/// Focus: the Gemeinde-scope check (assertCanManage) and the create/invite
/// branching. Prisma + SyncService faked in memory.
const GEMEINDE = { id: 'gem-1', name: 'Nord', kcId: 'kc-1', createdAt: new Date() };
function caller(memberships: AuthenticatedUser['memberships']): AuthenticatedUser {
return { userId: 'caller-1', authentikSub: 'sub-1', email: 'c@example.org', memberships };
}
const LT = caller([{ kcId: 'kc-1', gemeindeId: null, role: Role.LEITUNGSTEAM }]);
const VERANTW_GEM1 = caller([
{ kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_VERANTWORTLICHER },
]);
const VERANTW_GEM2 = caller([
{ kcId: 'kc-1', gemeindeId: 'gem-2', role: Role.GEMEINDE_VERANTWORTLICHER },
]);
function makeService(opts: { gemeinde?: typeof GEMEINDE | null; existingEmails?: string[] } = {}) {
const gemeinde = opts.gemeinde === undefined ? GEMEINDE : opts.gemeinde;
const emails = new Set(opts.existingEmails ?? []);
const created: Record<string, unknown> = {};
const prisma = {
gemeinde: { findUnique: jest.fn().mockResolvedValue(gemeinde) },
user: {
findUnique: jest.fn(({ where }: { where: { email: string } }) =>
Promise.resolve(emails.has(where.email) ? { id: 'dup', email: where.email } : null),
),
create: jest.fn(({ data }: { data: Record<string, unknown> }) => {
created.user = { id: 'u-1', createdAt: new Date(), ...data };
return Promise.resolve(created.user);
}),
delete: jest.fn().mockResolvedValue({ id: 'u-1' }),
},
membership: {
create: jest.fn(({ data }: { data: Record<string, unknown> }) => {
created.membership = { id: 'm-1', ...data };
return Promise.resolve(created.membership);
}),
findFirst: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue([]),
},
teamerInvite: {
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'inv-1', usedCount: 0, revokedAt: null, ...data }),
),
},
};
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
const service = new TeamerService(prisma as never, sync as never);
return { service, prisma, sync, created };
}
describe('TeamerService scope check', () => {
it('404s when the Gemeinde does not exist', async () => {
const { service } = makeService({ gemeinde: null });
await expect(service.listTeamer(LT, 'gem-x')).rejects.toBeInstanceOf(NotFoundException);
});
it('lets the Leitungsteam manage any Gemeinde', async () => {
const { service, prisma } = makeService();
await expect(service.listTeamer(LT, 'gem-1')).resolves.toEqual([]);
});
it('lets a Verantwortliche/r manage their own Gemeinde', async () => {
const { service, prisma } = makeService();
await expect(service.listTeamer(VERANTW_GEM1, 'gem-1')).resolves.toEqual([]);
});
it('forbids a Verantwortliche/r from managing a different Gemeinde', async () => {
const { service } = makeService();
await expect(service.listTeamer(VERANTW_GEM2, 'gem-1')).rejects.toBeInstanceOf(
ForbiddenException,
);
});
});
describe('TeamerService.createTeamer', () => {
it('rejects a duplicate email', async () => {
const { service } = makeService({ existingEmails: ['dup@example.org'] });
await expect(
service.createTeamer(VERANTW_GEM1, 'gem-1', {
firstName: 'A',
lastName: 'B',
email: 'dup@example.org',
password: 'password1',
}),
).rejects.toBeInstanceOf(ConflictException);
});
it('creates a hashed local account + GEMEINDE_TEAMER membership and hides the hash', async () => {
const { service, created, sync } = makeService();
const res = await service.createTeamer(VERANTW_GEM1, 'gem-1', {
firstName: 'Ada',
lastName: 'Lo',
email: 'Ada@Example.org',
password: 'password1',
});
expect(res).not.toHaveProperty('passwordHash');
expect(res.email).toBe('ada@example.org');
expect((created.user as { kcId: string }).kcId).toBe('kc-1');
expect(
await bcrypt.compare('password1', (created.user as { passwordHash: string }).passwordHash),
).toBe(true);
expect((created.membership as { role: Role }).role).toBe(Role.GEMEINDE_TEAMER);
expect((created.membership as { gemeindeId: string }).gemeindeId).toBe('gem-1');
expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', 'u-1', expect.anything());
expect(sync.capture).toHaveBeenCalledWith('Membership', 'CREATE', 'm-1', expect.anything());
});
});
describe('TeamerService.createInvite', () => {
it('defaults a group link to unlimited uses and no expiry', async () => {
const { service } = makeService();
const inv = await service.createInvite(LT, 'gem-1', {});
expect(inv.email).toBeNull();
expect(inv.maxUses).toBeNull();
expect(inv.expiresAt).toBeNull();
expect(inv.token).toEqual(expect.any(String));
});
it('defaults a personal invite to a single use and lowercases the email', async () => {
const { service } = makeService();
const inv = await service.createInvite(LT, 'gem-1', { email: 'New@Example.org' });
expect(inv.email).toBe('new@example.org');
expect(inv.maxUses).toBe(1);
});
it('turns expiresInHours into a concrete expiry', async () => {
const { service } = makeService();
const before = Date.now();
const inv = await service.createInvite(LT, 'gem-1', { expiresInHours: 48 });
const ms = (inv.expiresAt as Date).getTime() - before;
expect(ms).toBeGreaterThan(47 * 3600_000);
expect(ms).toBeLessThan(49 * 3600_000);
});
});
describe('TeamerService.removeTeamer', () => {
it('404s when the user is not a local Teamer of that Gemeinde', async () => {
const { service } = makeService();
await expect(service.removeTeamer(LT, 'gem-1', 'u-9')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('deletes the account and captures a User DELETE', async () => {
const { service, prisma, sync } = makeService();
prisma.membership.findFirst = jest
.fn()
.mockResolvedValue({ userId: 'u-1', gemeindeId: 'gem-1', user: { passwordHash: 'h' } });
const res = await service.removeTeamer(LT, 'gem-1', 'u-1');
expect(res).toEqual({ id: 'u-1' });
expect(prisma.user.delete).toHaveBeenCalledWith({ where: { id: 'u-1' } });
expect(sync.capture).toHaveBeenCalledWith('User', 'DELETE', 'u-1', { id: 'u-1' });
});
});
+182
View File
@@ -0,0 +1,182 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { randomBytes } from 'crypto';
import { Role, SyncOperation } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import { AuthenticatedUser } from '../auth/authenticated-request';
import { CreateTeamerInviteDto } from './dto/create-teamer-invite.dto';
const BCRYPT_ROUNDS = 10;
type PublicUser = {
id: string;
email: string;
firstName: string;
lastName: string;
createdAt: Date;
};
/// Management of local Gemeinde Teamer accounts and their invites. Callable by
/// the Leitungsteam (any Gemeinde) or by a Gemeinde Verantwortliche/r for
/// their own Gemeinde only.
@Injectable()
export class TeamerService {
constructor(
private readonly prisma: PrismaClient,
private readonly sync: SyncService,
) {}
async createTeamer(
caller: AuthenticatedUser,
gemeindeId: string,
input: { firstName: string; lastName: string; email: string; password: string },
): Promise<PublicUser> {
const gemeinde = await this.assertCanManage(caller, gemeindeId);
const email = input.email.toLowerCase();
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: gemeinde.kcId,
},
});
const membership = await this.prisma.membership.create({
data: {
userId: user.id,
kcId: gemeinde.kcId,
gemeindeId,
role: Role.GEMEINDE_TEAMER,
},
});
await this.sync.capture('User', SyncOperation.CREATE, user.id, user);
await this.sync.capture('Membership', SyncOperation.CREATE, membership.id, membership);
return toPublicUser(user);
}
async listTeamer(caller: AuthenticatedUser, gemeindeId: string): Promise<PublicUser[]> {
await this.assertCanManage(caller, gemeindeId);
const memberships = await this.prisma.membership.findMany({
where: { gemeindeId, role: Role.GEMEINDE_TEAMER },
include: { user: true },
orderBy: { user: { lastName: 'asc' } },
});
return memberships.map((m) => toPublicUser(m.user));
}
async removeTeamer(
caller: AuthenticatedUser,
gemeindeId: string,
userId: string,
): Promise<{ id: string }> {
await this.assertCanManage(caller, gemeindeId);
const membership = await this.prisma.membership.findFirst({
where: { userId, gemeindeId, role: Role.GEMEINDE_TEAMER },
include: { user: true },
});
if (!membership || !membership.user.passwordHash) {
throw new NotFoundException('No local Teamer account for this Gemeinde');
}
await this.prisma.user.delete({ where: { id: userId } });
await this.sync.capture('User', SyncOperation.DELETE, userId, { id: userId });
return { id: userId };
}
async createInvite(
caller: AuthenticatedUser,
gemeindeId: string,
dto: CreateTeamerInviteDto,
) {
const gemeinde = await this.assertCanManage(caller, gemeindeId);
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.teamerInvite.create({
data: {
kcId: gemeinde.kcId,
gemeindeId,
token: randomBytes(24).toString('base64url'),
email,
maxUses,
expiresAt,
createdByUserId: caller.userId,
},
});
await this.sync.capture('TeamerInvite', SyncOperation.CREATE, invite.id, invite);
return invite;
}
async listInvites(caller: AuthenticatedUser, gemeindeId: string) {
await this.assertCanManage(caller, gemeindeId);
return this.prisma.teamerInvite.findMany({
where: { gemeindeId },
orderBy: { createdAt: 'desc' },
});
}
async revokeInvite(caller: AuthenticatedUser, gemeindeId: string, inviteId: string) {
await this.assertCanManage(caller, gemeindeId);
const invite = await this.prisma.teamerInvite.findFirst({
where: { id: inviteId, gemeindeId },
});
if (!invite) {
throw new NotFoundException('Invite not found');
}
const updated = await this.prisma.teamerInvite.update({
where: { id: inviteId },
data: { revokedAt: new Date() },
});
await this.sync.capture('TeamerInvite', SyncOperation.UPDATE, updated.id, updated);
return updated;
}
/// LT may manage every Gemeinde; a Verantwortliche/r only the one they hold
/// that role for. Returns the Gemeinde (for its kcId) on success.
private async assertCanManage(caller: AuthenticatedUser, gemeindeId: string) {
const gemeinde = await this.prisma.gemeinde.findUnique({ where: { id: gemeindeId } });
if (!gemeinde) {
throw new NotFoundException('Gemeinde not found');
}
const isLeitungsteam = caller.memberships.some(
(m) => m.role === Role.LEITUNGSTEAM,
);
const isVerantwortlich = caller.memberships.some(
(m) => m.role === Role.GEMEINDE_VERANTWORTLICHER && m.gemeindeId === gemeindeId,
);
if (!isLeitungsteam && !isVerantwortlich) {
throw new ForbiddenException('Not responsible for this Gemeinde');
}
return gemeinde;
}
}
function toPublicUser(user: {
id: string;
email: string;
firstName: string;
lastName: string;
createdAt: Date;
}): PublicUser {
return {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
createdAt: user.createdAt,
};
}