feat: Wahl-Phasen, Verantwortliche-Invites, Auth fixes, GRUPPE chat #1

Merged
linus merged 28 commits from feat/backend-phases-0-6 into main 2026-09-12 11:26:16 +00:00
11 changed files with 524 additions and 13 deletions
Showing only changes of commit 6ed5aa2c76 - Show all commits
+13 -3
View File
@@ -59,6 +59,15 @@ client's host - no separate web server is needed.
or a Verantwortliche/r for their own Gemeinde (enforced in `TeamerService`, or a Verantwortliche/r for their own Gemeinde (enforced in `TeamerService`,
since `RolesGuard` only scopes by `kcId`). Files/chat read endpoints accept since `RolesGuard` only scopes by `kcId`). Files/chat read endpoints accept
`'team'` tokens too, so Teamer see non-Konfi files and chat. `'team'` tokens too, so Teamer see non-Konfi files and chat.
- `onboarding/` — self-registration for Gemeinde Verantwortliche.
`GET /onboarding/kc/:inviteCode` (public) returns the KC name + its
Gemeinden to pick from. `POST /onboarding/verantwortliche` takes the
caller's raw Authentik bearer token (no local `Membership` needed yet),
JIT-provisions the local `User` from the token claims, and creates a
`Membership` with `status = PENDING`. Leitungsteam reviews via
`GET /onboarding/requests?kcId=` and `POST /onboarding/requests/:id/approve`
or `.../reject`. Auth strategies only load `ACTIVE` memberships, so a
pending request grants nothing until approved.
- `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest - `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest
Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`: Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`:
a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments → a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments →
@@ -96,7 +105,8 @@ client's host - no separate web server is needed.
Leitungsteam roles are global across all KCs). Leitungsteam roles are global across all KCs).
All planned backend phases are implemented. `npm test` runs Jest unit tests All planned backend phases are implemented. `npm test` runs Jest unit tests
(`ZuteilungService`, `TeamAuthService`, `TeamerService`; Prisma mocked). (`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`;
Remaining work: the Flutter clients (see repo root README), Authentik Prisma mocked). Remaining work: the Flutter clients (see repo root README),
provisioning for LT/Verantwortliche, and the first real Prisma migration Authentik JIT provisioning for LT (Verantwortliche already self-provision via
`onboarding/`), invite email delivery, and the first real Prisma migration
(only `schema.prisma` exists so far). (only `schema.prisma` exists so far).
+11 -2
View File
@@ -47,6 +47,14 @@ enum Role {
GEMEINDE_TEAMER GEMEINDE_TEAMER
} }
/// PENDING memberships come from self-registration and grant no rights until
/// a Leitungsteam member approves them. Everything created by LT/Verantwortliche
/// directly is ACTIVE from the start.
enum MembershipStatus {
ACTIVE
PENDING
}
/// A team member account. Leitungsteam and Gemeinde Verantwortliche are /// A team member account. Leitungsteam and Gemeinde Verantwortliche are
/// Authentik-backed (`authentikSub` set, `passwordHash` null). Gemeinde /// Authentik-backed (`authentikSub` set, `passwordHash` null). Gemeinde
/// Teamer are local accounts created by a Verantwortliche/r (`passwordHash` /// Teamer are local accounts created by a Verantwortliche/r (`passwordHash`
@@ -70,12 +78,13 @@ model User {
/// Scopes a User's role to a specific Kc (and Gemeinde, if applicable). /// Scopes a User's role to a specific Kc (and Gemeinde, if applicable).
/// LEITUNGSTEAM memberships apply to all Kcs implicitly and omit gemeindeId. /// LEITUNGSTEAM memberships apply to all Kcs implicitly and omit gemeindeId.
model Membership { model Membership {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
kcId String kcId String
gemeindeId String? gemeindeId String?
role Role role Role
createdAt DateTime @default(now()) status MembershipStatus @default(ACTIVE)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
+2
View File
@@ -7,6 +7,7 @@ import { AuthModule } from './auth/auth.module';
import { KcModule } from './kc/kc.module'; import { KcModule } from './kc/kc.module';
import { GemeindeModule } from './gemeinde/gemeinde.module'; import { GemeindeModule } from './gemeinde/gemeinde.module';
import { TeamerModule } from './teamer/teamer.module'; import { TeamerModule } from './teamer/teamer.module';
import { OnboardingModule } from './onboarding/onboarding.module';
import { WahlModule } from './wahl/wahl.module'; import { WahlModule } from './wahl/wahl.module';
import { FilesModule } from './files/files.module'; import { FilesModule } from './files/files.module';
import { ChatModule } from './chat/chat.module'; import { ChatModule } from './chat/chat.module';
@@ -27,6 +28,7 @@ import { SyncModule } from './sync/sync.module';
KcModule, KcModule,
GemeindeModule, GemeindeModule,
TeamerModule, TeamerModule,
OnboardingModule,
WahlModule, WahlModule,
FilesModule, FilesModule,
ChatModule, ChatModule,
+1 -1
View File
@@ -43,7 +43,7 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> { async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { authentikSub: payload.sub }, where: { authentikSub: payload.sub },
include: { memberships: true }, include: { memberships: { where: { status: 'ACTIVE' } } },
}); });
if (!user) { if (!user) {
throw new UnauthorizedException('User not provisioned locally yet'); throw new UnauthorizedException('User not provisioned locally yet');
+1 -1
View File
@@ -141,7 +141,7 @@ export class TeamAuthService {
async resolve(userId: string): Promise<AuthenticatedUser> { async resolve(userId: string): Promise<AuthenticatedUser> {
const user = await this.prisma.user.findFirst({ const user = await this.prisma.user.findFirst({
where: { id: userId, passwordHash: { not: null } }, where: { id: userId, passwordHash: { not: null } },
include: { memberships: true }, include: { memberships: { where: { status: 'ACTIVE' } } },
}); });
if (!user) { if (!user) {
throw new UnauthorizedException('Team account no longer exists'); throw new UnauthorizedException('Team account no longer exists');
+28 -6
View File
@@ -25,7 +25,15 @@ export class TokenVerificationService {
this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true }); this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true });
} }
async verifyAuthentik(token: string): Promise<AuthenticatedUser> { /// Verifies an Authentik token's signature and returns its identity claims,
/// without requiring a local User to exist yet (used by the onboarding
/// self-registration path, which provisions that User).
async verifyAuthentikClaims(token: string): Promise<{
sub: string;
email: string;
firstName: string;
lastName: string;
}> {
const decoded = jwt.decode(token, { complete: true }); const decoded = jwt.decode(token, { complete: true });
const kid = decoded?.header.kid; const kid = decoded?.header.kid;
if (!kid) { if (!kid) {
@@ -35,14 +43,28 @@ export class TokenVerificationService {
const payload = jwt.verify(token, key.getPublicKey(), { const payload = jwt.verify(token, key.getPublicKey(), {
issuer: this.issuerUrl, issuer: this.issuerUrl,
algorithms: ['RS256'], algorithms: ['RS256'],
}) as jwt.JwtPayload; }) as jwt.JwtPayload & {
if (!payload.sub) { email?: string;
throw new UnauthorizedException('Authentik token missing subject'); given_name?: string;
family_name?: string;
};
if (!payload.sub || !payload.email) {
throw new UnauthorizedException('Authentik token missing subject or email');
} }
return {
sub: payload.sub,
email: payload.email,
firstName: payload.given_name ?? '',
lastName: payload.family_name ?? '',
};
}
async verifyAuthentik(token: string): Promise<AuthenticatedUser> {
const { sub } = await this.verifyAuthentikClaims(token);
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { authentikSub: payload.sub }, where: { authentikSub: sub },
include: { memberships: true }, include: { memberships: { where: { status: 'ACTIVE' } } },
}); });
if (!user) { if (!user) {
throw new UnauthorizedException('User not provisioned locally yet'); throw new UnauthorizedException('User not provisioned locally yet');
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class RegisterVerantwortlicheDto {
@IsString()
@IsNotEmpty()
inviteCode!: string;
@IsString()
@IsNotEmpty()
gemeindeId!: string;
}
+67
View File
@@ -0,0 +1,67 @@
import {
Body,
Controller,
Get,
Headers,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { OnboardingService } from './onboarding.service';
import { RegisterVerantwortlicheDto } from './dto/register-verantwortliche.dto';
import { Roles } from '../common/roles.decorator';
import { RolesGuard } from '../common/roles.guard';
import { Role } from '../common/role.enum';
function bearer(header?: string): string | undefined {
return header?.startsWith('Bearer ') ? header.slice('Bearer '.length) : undefined;
}
@Controller('onboarding')
export class OnboardingController {
constructor(private readonly onboarding: OnboardingService) {}
/// Public lookup: invite code -> KC name + selectable Gemeinden.
@Get('kc/:inviteCode')
resolveInvite(@Param('inviteCode') inviteCode: string) {
return this.onboarding.resolveInvite(inviteCode);
}
/// Self-registration as Gemeinde Verantwortliche/r. Authenticated by the
/// caller's raw Authentik bearer token (no local Membership required yet).
@Post('verantwortliche')
registerVerantwortliche(
@Body() dto: RegisterVerantwortlicheDto,
@Headers('authorization') authorization?: string,
) {
return this.onboarding.registerVerantwortliche(
bearer(authorization),
dto.inviteCode,
dto.gemeindeId,
);
}
/// Leitungsteam: review and act on pending self-registrations.
@Get('requests')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
listRequests(@Query('kcId') kcId: string) {
return this.onboarding.listRequests(kcId);
}
@Post('requests/:membershipId/approve')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
approve(@Param('membershipId') membershipId: string) {
return this.onboarding.approve(membershipId);
}
@Post('requests/:membershipId/reject')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
reject(@Param('membershipId') membershipId: string) {
return this.onboarding.reject(membershipId);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { OnboardingService } from './onboarding.service';
import { OnboardingController } from './onboarding.controller';
@Module({
imports: [AuthModule],
providers: [OnboardingService],
controllers: [OnboardingController],
})
export class OnboardingModule {}
+224
View File
@@ -0,0 +1,224 @@
import {
BadRequestException,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { MembershipStatus, Role } from '@prisma/client';
import { OnboardingService } from './onboarding.service';
/// Prisma / Sync / TokenVerification faked in memory.
const CLAIMS = {
sub: 'authentik-sub-1',
email: 'Vera@example.org',
firstName: 'Vera',
lastName: 'Wong',
};
function makeService(seed: {
kc?: { id: string; name: string; inviteCode: string; isActive: boolean } | null;
gemeinde?: { id: string; name: string; kcId: string } | null;
user?: { id: string; authentikSub: string } | null;
membership?: {
id: string;
status: MembershipStatus;
userId: string;
kcId: string;
gemeindeId: string;
} | null;
tokenThrows?: boolean;
}) {
const state = {
membership: seed.membership ?? null,
};
const prisma = {
kc: {
findUnique: jest.fn().mockResolvedValue(
seed.kc === undefined
? { id: 'kc-1', name: 'KC 2026', inviteCode: 'code-1', isActive: true, gemeinden: [] }
: seed.kc,
),
},
gemeinde: {
findUnique: jest.fn().mockResolvedValue(
seed.gemeinde === undefined ? { id: 'gem-1', name: 'Nord', kcId: 'kc-1' } : seed.gemeinde,
),
},
user: {
findUnique: jest.fn().mockResolvedValue(seed.user ?? null),
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'u-new', ...data }),
),
},
membership: {
findUnique: jest.fn(() => Promise.resolve(state.membership)),
findMany: jest.fn().mockResolvedValue([]),
create: jest.fn(({ data }: { data: Record<string, unknown> }) => {
state.membership = { id: 'mem-new', ...data } as never;
return Promise.resolve(state.membership);
}),
update: jest.fn(({ data }: { data: { status: MembershipStatus } }) => {
state.membership = { ...state.membership!, ...data };
return Promise.resolve(state.membership);
}),
delete: jest.fn(() => Promise.resolve(state.membership!)),
},
};
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
const tokens = {
verifyAuthentikClaims: seed.tokenThrows
? jest.fn().mockRejectedValue(new UnauthorizedException('bad token'))
: jest.fn().mockResolvedValue(CLAIMS),
};
const service = new OnboardingService(prisma as never, sync as never, tokens as never);
return { service, prisma, sync, tokens };
}
describe('OnboardingService.resolveInvite', () => {
it('404s an unknown code', async () => {
const { service } = makeService({ kc: null });
await expect(service.resolveInvite('nope')).rejects.toBeInstanceOf(NotFoundException);
});
it('404s an inactive KC', async () => {
const { service } = makeService({
kc: { id: 'kc-1', name: 'KC', inviteCode: 'c', isActive: false },
});
await expect(service.resolveInvite('c')).rejects.toBeInstanceOf(NotFoundException);
});
it('returns the KC name and its Gemeinden', async () => {
const { service, prisma } = makeService({});
prisma.kc.findUnique = jest.fn().mockResolvedValue({
id: 'kc-1',
name: 'KC 2026',
isActive: true,
gemeinden: [{ id: 'gem-1', name: 'Nord' }],
});
await expect(service.resolveInvite('code-1')).resolves.toEqual({
kcId: 'kc-1',
kcName: 'KC 2026',
gemeinden: [{ id: 'gem-1', name: 'Nord' }],
});
});
});
describe('OnboardingService.registerVerantwortliche', () => {
it('rejects a missing token', async () => {
const { service } = makeService({});
await expect(
service.registerVerantwortliche(undefined, 'code-1', 'gem-1'),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('propagates an invalid token', async () => {
const { service } = makeService({ tokenThrows: true });
await expect(
service.registerVerantwortliche('t', 'code-1', 'gem-1'),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('404s an unknown invite code', async () => {
const { service } = makeService({ kc: null });
await expect(
service.registerVerantwortliche('t', 'bad', 'gem-1'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('400s when the Gemeinde is not part of the KC', async () => {
const { service } = makeService({ gemeinde: { id: 'gem-9', name: 'X', kcId: 'other-kc' } });
await expect(
service.registerVerantwortliche('t', 'code-1', 'gem-9'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('provisions the user and creates a PENDING membership', async () => {
const { service, prisma, sync } = makeService({ user: null, membership: null });
const res = await service.registerVerantwortliche('t', 'code-1', 'gem-1');
expect(prisma.user.create).toHaveBeenCalledWith({
data: expect.objectContaining({
authentikSub: 'authentik-sub-1',
email: 'vera@example.org',
}),
});
expect(prisma.membership.create).toHaveBeenCalledWith({
data: expect.objectContaining({
role: Role.GEMEINDE_VERANTWORTLICHER,
status: MembershipStatus.PENDING,
gemeindeId: 'gem-1',
}),
});
expect(res).toMatchObject({ status: MembershipStatus.PENDING, kcName: 'KC 2026' });
expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', expect.any(String), expect.anything());
expect(sync.capture).toHaveBeenCalledWith('Membership', 'CREATE', expect.any(String), expect.anything());
});
it('does not re-create the user when one already exists', async () => {
const { service, prisma } = makeService({
user: { id: 'u-1', authentikSub: 'authentik-sub-1' },
membership: null,
});
await service.registerVerantwortliche('t', 'code-1', 'gem-1');
expect(prisma.user.create).not.toHaveBeenCalled();
expect(prisma.membership.create).toHaveBeenCalled();
});
it('returns the existing membership status without creating a second one', async () => {
const { service, prisma } = makeService({
user: { id: 'u-1', authentikSub: 'authentik-sub-1' },
membership: {
id: 'mem-1',
status: MembershipStatus.ACTIVE,
userId: 'u-1',
kcId: 'kc-1',
gemeindeId: 'gem-1',
},
});
const res = await service.registerVerantwortliche('t', 'code-1', 'gem-1');
expect(res).toMatchObject({ membershipId: 'mem-1', status: MembershipStatus.ACTIVE });
expect(prisma.membership.create).not.toHaveBeenCalled();
});
});
describe('OnboardingService.approve / reject', () => {
const pending = {
id: 'mem-1',
status: MembershipStatus.PENDING,
userId: 'u-1',
kcId: 'kc-1',
gemeindeId: 'gem-1',
};
it('404s approving an unknown request', async () => {
const { service } = makeService({ membership: null });
await expect(service.approve('mem-x')).rejects.toBeInstanceOf(NotFoundException);
});
it('400s approving a non-pending request', async () => {
const { service } = makeService({
membership: { ...pending, status: MembershipStatus.ACTIVE },
});
await expect(service.approve('mem-1')).rejects.toBeInstanceOf(BadRequestException);
});
it('flips the status to ACTIVE and captures the update', async () => {
const { service, prisma, sync } = makeService({ membership: { ...pending } });
await service.approve('mem-1');
expect(prisma.membership.update).toHaveBeenCalledWith({
where: { id: 'mem-1' },
data: { status: MembershipStatus.ACTIVE },
});
expect(sync.capture).toHaveBeenCalledWith('Membership', 'UPDATE', 'mem-1', expect.anything());
});
it('deletes on reject and captures the delete', async () => {
const { service, prisma, sync } = makeService({ membership: { ...pending } });
const res = await service.reject('mem-1');
expect(res).toEqual({ id: 'mem-1' });
expect(prisma.membership.delete).toHaveBeenCalledWith({ where: { id: 'mem-1' } });
expect(sync.capture).toHaveBeenCalledWith('Membership', 'DELETE', 'mem-1', { id: 'mem-1' });
});
});
+155
View File
@@ -0,0 +1,155 @@
import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { MembershipStatus, Role, SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import { TokenVerificationService } from '../auth/token-verification.service';
/// Self-service onboarding for Gemeinde Verantwortliche. The person signs in
/// with their Konfi-Castle-ID (Authentik) and submits a KC invite code plus
/// the Gemeinde they belong to; this provisions their local User (JIT) and a
/// PENDING membership that a Leitungsteam member must approve before it grants
/// any rights.
@Injectable()
export class OnboardingService {
constructor(
private readonly prisma: PrismaClient,
private readonly sync: SyncService,
private readonly tokens: TokenVerificationService,
) {}
/// Public: resolves an invite code to the KC name and its Gemeinden so the
/// registrant can pick theirs. The code itself is the shared secret.
async resolveInvite(inviteCode: string) {
const kc = await this.prisma.kc.findUnique({
where: { inviteCode },
include: {
gemeinden: { select: { id: true, name: true }, orderBy: { name: 'asc' } },
},
});
if (!kc || !kc.isActive) {
throw new NotFoundException('Unknown or inactive KC invite code');
}
return { kcId: kc.id, kcName: kc.name, gemeinden: kc.gemeinden };
}
async registerVerantwortliche(token: string | undefined, inviteCode: string, gemeindeId: string) {
if (!token) {
throw new UnauthorizedException('Missing Authentik bearer token');
}
const claims = await this.tokens.verifyAuthentikClaims(token);
const kc = await this.prisma.kc.findUnique({ where: { inviteCode } });
if (!kc || !kc.isActive) {
throw new NotFoundException('Unknown or inactive KC invite code');
}
const gemeinde = await this.prisma.gemeinde.findUnique({ where: { id: gemeindeId } });
if (!gemeinde || gemeinde.kcId !== kc.id) {
throw new BadRequestException('Gemeinde does not belong to this KC');
}
const user = await this.upsertUser(claims);
const existing = await this.prisma.membership.findUnique({
where: {
userId_kcId_gemeindeId: { userId: user.id, kcId: kc.id, gemeindeId },
},
});
if (existing) {
return this.summary(existing.id, existing.status, kc.name, gemeinde.name);
}
const membership = await this.prisma.membership.create({
data: {
userId: user.id,
kcId: kc.id,
gemeindeId,
role: Role.GEMEINDE_VERANTWORTLICHER,
status: MembershipStatus.PENDING,
},
});
await this.sync.capture('Membership', SyncOperation.CREATE, membership.id, membership);
return this.summary(membership.id, membership.status, kc.name, gemeinde.name);
}
async listRequests(kcId: string) {
return this.prisma.membership.findMany({
where: {
kcId,
status: MembershipStatus.PENDING,
role: Role.GEMEINDE_VERANTWORTLICHER,
},
include: {
user: { select: { id: true, email: true, firstName: true, lastName: true } },
gemeinde: { select: { id: true, name: true } },
},
orderBy: { createdAt: 'asc' },
});
}
async approve(membershipId: string) {
await this.getPendingOrThrow(membershipId);
const membership = await this.prisma.membership.update({
where: { id: membershipId },
data: { status: MembershipStatus.ACTIVE },
});
await this.sync.capture('Membership', SyncOperation.UPDATE, membership.id, membership);
return membership;
}
async reject(membershipId: string) {
await this.getPendingOrThrow(membershipId);
const membership = await this.prisma.membership.delete({ where: { id: membershipId } });
await this.sync.capture('Membership', SyncOperation.DELETE, membership.id, { id: membership.id });
return { id: membership.id };
}
private async getPendingOrThrow(membershipId: string) {
const membership = await this.prisma.membership.findUnique({ where: { id: membershipId } });
if (!membership) {
throw new NotFoundException('Request not found');
}
if (membership.status !== MembershipStatus.PENDING) {
throw new BadRequestException('Request is not pending');
}
return membership;
}
private async upsertUser(claims: {
sub: string;
email: string;
firstName: string;
lastName: string;
}) {
const email = claims.email.toLowerCase();
const existing = await this.prisma.user.findUnique({
where: { authentikSub: claims.sub },
});
if (existing) {
return existing;
}
const user = await this.prisma.user.create({
data: {
authentikSub: claims.sub,
email,
firstName: claims.firstName,
lastName: claims.lastName,
},
});
await this.sync.capture('User', SyncOperation.CREATE, user.id, user);
return user;
}
private summary(
membershipId: string,
status: MembershipStatus,
kcName: string,
gemeindeName: string,
) {
return { membershipId, status, kcName, gemeindeName };
}
}