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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user