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>
183 lines
5.8 KiB
TypeScript
183 lines
5.8 KiB
TypeScript
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,
|
|
};
|
|
}
|