From d48c07b0e4819762d2b5f07d2967661be037d207 Mon Sep 17 00:00:00 2001 From: linus Date: Wed, 9 Sep 2026 16:50:06 +0200 Subject: [PATCH] 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 --- .env.example | 3 + README.md | 36 +++- package-lock.json | 18 ++ package.json | 2 + prisma/schema.prisma | 52 ++++- src/app.module.ts | 2 + src/auth/auth.controller.ts | 20 +- src/auth/auth.module.ts | 13 +- src/auth/authenticated-request.ts | 6 +- src/auth/dto/register-teamer.dto.ts | 30 +++ src/auth/dto/team-login.dto.ts | 10 + src/auth/team-auth.service.spec.ts | 232 +++++++++++++++++++++ src/auth/team-auth.service.ts | 160 ++++++++++++++ src/auth/team-jwt.strategy.ts | 26 +++ src/auth/token-verification.service.ts | 9 +- src/chat/chat.controller.ts | 9 +- src/files/files.controller.ts | 4 +- src/sync/sync.service.ts | 3 + src/teamer/dto/create-teamer-invite.dto.ts | 21 ++ src/teamer/dto/create-teamer.dto.ts | 18 ++ src/teamer/teamer.controller.ts | 74 +++++++ src/teamer/teamer.module.ts | 9 + src/teamer/teamer.service.spec.ts | 167 +++++++++++++++ src/teamer/teamer.service.ts | 182 ++++++++++++++++ 24 files changed, 1076 insertions(+), 30 deletions(-) create mode 100644 src/auth/dto/register-teamer.dto.ts create mode 100644 src/auth/dto/team-login.dto.ts create mode 100644 src/auth/team-auth.service.spec.ts create mode 100644 src/auth/team-auth.service.ts create mode 100644 src/auth/team-jwt.strategy.ts create mode 100644 src/teamer/dto/create-teamer-invite.dto.ts create mode 100644 src/teamer/dto/create-teamer.dto.ts create mode 100644 src/teamer/teamer.controller.ts create mode 100644 src/teamer/teamer.module.ts create mode 100644 src/teamer/teamer.service.spec.ts create mode 100644 src/teamer/teamer.service.ts diff --git a/.env.example b/.env.example index 11e1602..bced9a5 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,9 @@ AUTHENTIK_ISSUER_URL="https://authentik.example.org/application/o/kc-app" # Secret used to sign guest/Konfi session tokens (local accounts only) GUEST_JWT_SECRET="change-me" +# Secret used to sign local Gemeinde Teamer session tokens (password login) +TEAM_JWT_SECRET="change-me-too" + PORT=3000 # File storage: defaults to Nextcloud via WebDAV; set STORAGE_PROVIDER=s3 to diff --git a/README.md b/README.md index 6d347c3..fd8764e 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ architecture context). ```bash npm install -cp .env.example .env # then fill in DATABASE_URL / AUTHENTIK_ISSUER_URL / GUEST_JWT_SECRET +cp .env.example .env # then fill in DATABASE_URL / AUTHENTIK_ISSUER_URL / GUEST_JWT_SECRET / TEAM_JWT_SECRET npx prisma generate npx prisma migrate dev --name init # requires a running PostgreSQL instance npm run start:dev @@ -20,12 +20,20 @@ client's host - no separate web server is needed. ## Auth model -- Team members (Leitungsteam, Gemeinde Verantwortliche, Gemeinde Teamer) are - provisioned in Authentik; this API acts as an OIDC **resource server**, +- Leitungsteam and Gemeinde Verantwortliche are provisioned in Authentik + (the "Konfi-Castle-ID"); this API acts as an OIDC **resource server**, verifying access tokens against Authentik's JWKS (`AuthentikStrategy`) and then resolving local `Membership` rows to determine role + KC/Gemeinde scope. Clients perform the actual Authorization Code + PKCE flow against Authentik directly. +- Gemeinde Teamer are **local accounts** (no Authentik): a `User` row with a + `passwordHash` and `kcId` set, `authentikSub` left null. A Gemeinde + Verantwortliche/r creates them directly or via a `TeamerInvite` + (shareable group link or per-email invite). Login is `POST /auth/team-login` + (email + password) or `POST /auth/teamer/register` (redeem an invite + token); both return a JWT signed with `TEAM_JWT_SECRET` and carrying + `typ: "team"`. `TeamJwtStrategy` (`AuthGuard('team')`) resolves it to the + same shape as `AuthentikStrategy`, so guards/controllers treat both alike. - Guests/Konfis get a temporary local account (first/last name required, no Authentik) created via `POST /auth/guest` with a KC invite code, returning a JWT signed with `GUEST_JWT_SECRET`. @@ -33,13 +41,24 @@ client's host - no separate web server is needed. ## Modules implemented so far - `prisma/` — shared `PrismaClient` provider. -- `auth/` — Authentik resource-server strategy (`AuthGuard('authentik')`) + - guest invite-code login issuing a locally-signed JWT (`AuthGuard('guest')`). +- `auth/` — Authentik resource-server strategy (`AuthGuard('authentik')`), + guest invite-code login (`AuthGuard('guest')`), and local Gemeinde Teamer + auth (`AuthGuard('team')`): `POST /auth/team-login` and + `POST /auth/teamer/register` (invite redemption), bcrypt hashes, tokens + signed with `TEAM_JWT_SECRET`. `TokenVerificationService` (WS handshake) + now accepts Authentik, team, or guest tokens. - `kc/` — KC (event) creation/listing, Leitungsteam-only. - `gemeinde/` — Gemeinde (congregation) CRUD per KC (`POST /gemeinde`, `GET /gemeinde?kcId=`, `GET/PATCH/DELETE /gemeinde/:id`), Leitungsteam-only. Gemeinde Verantwortliche/Teamer get their own Gemeinde from their `Membership`, not from this endpoint. +- `teamer/` — local Gemeinde Teamer accounts + invites, under + `/gemeinde/:gemeindeId/...`: `POST/GET teamer`, + `DELETE teamer/:userId`, `POST/GET teamer-invites`, + `DELETE teamer-invites/:inviteId`. Callable by Leitungsteam (any Gemeinde) + or a Verantwortliche/r for their own Gemeinde (enforced in `TeamerService`, + since `RolesGuard` only scopes by `kcId`). Files/chat read endpoints accept + `'team'` tokens too, so Teamer see non-Konfi files and chat. - `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`: a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments → @@ -76,5 +95,8 @@ client's host - no separate web server is needed. - `common/` — `Role` enum, `@Roles()` decorator, `RolesGuard` (KC-scoped, Leitungsteam roles are global across all KCs). -All planned backend phases are implemented; remaining work is the Flutter -clients (see repo root README). +All planned backend phases are implemented. `npm test` runs Jest unit tests +(`ZuteilungService`, `TeamAuthService`, `TeamerService`; Prisma mocked). +Remaining work: the Flutter clients (see repo root README), Authentik +provisioning for LT/Verantwortliche, and the first real Prisma migration +(only `schema.prisma` exists so far). diff --git a/package-lock.json b/package-lock.json index 31b3040..5b3c298 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@nestjs/serve-static": "^4.0.2", "@nestjs/websockets": "^10.4.15", "@prisma/client": "^5.22.0", + "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "jsonwebtoken": "^9.0.2", @@ -37,6 +38,7 @@ "@nestjs/cli": "^10.4.9", "@nestjs/schematics": "^10.2.3", "@nestjs/testing": "^10.4.15", + "@types/bcryptjs": "^2.4.6", "@types/express": "^4.17.21", "@types/jest": "^29.5.14", "@types/jsonwebtoken": "^9.0.7", @@ -2776,6 +2778,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -3923,6 +3932,15 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", diff --git a/package.json b/package.json index 35e3b9d..61c95b8 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@nestjs/serve-static": "^4.0.2", "@nestjs/websockets": "^10.4.15", "@prisma/client": "^5.22.0", + "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "jsonwebtoken": "^9.0.2", @@ -49,6 +50,7 @@ "@nestjs/cli": "^10.4.9", "@nestjs/schematics": "^10.2.3", "@nestjs/testing": "^10.4.15", + "@types/bcryptjs": "^2.4.6", "@types/express": "^4.17.21", "@types/jest": "^29.5.14", "@types/jsonwebtoken": "^9.0.7", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 112c711..5812b27 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,12 +16,14 @@ model Kc { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - gemeinden Gemeinde[] - memberships Membership[] - wahlen Wahl[] - files File[] - channels ChatChannel[] - guests GuestAccount[] + gemeinden Gemeinde[] + memberships Membership[] + wahlen Wahl[] + files File[] + channels ChatChannel[] + guests GuestAccount[] + localUsers User[] + teamerInvites TeamerInvite[] } /// A local congregation/community participating in one Kc. @@ -31,9 +33,10 @@ model Gemeinde { kcId String createdAt DateTime @default(now()) - kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) - memberships Membership[] - guests GuestAccount[] + kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) + memberships Membership[] + guests GuestAccount[] + teamerInvites TeamerInvite[] @@unique([kcId, name]) } @@ -44,15 +47,21 @@ enum Role { GEMEINDE_TEAMER } -/// Authentik-backed user (team member with elevated rights). +/// A team member account. Leitungsteam and Gemeinde Verantwortliche are +/// Authentik-backed (`authentikSub` set, `passwordHash` null). Gemeinde +/// Teamer are local accounts created by a Verantwortliche/r (`passwordHash` +/// set, `authentikSub` null, `kcId` set) and, like guests, scoped to one KC. model User { id String @id @default(cuid()) - authentikSub String @unique + authentikSub String? @unique email String @unique firstName String lastName String + passwordHash String? + kcId String? createdAt DateTime @default(now()) + kc Kc? @relation(fields: [kcId], references: [id], onDelete: Cascade) memberships Membership[] messages ChatMessage[] chatParticipations ChatParticipant[] @@ -90,6 +99,27 @@ model GuestAccount { teilnehmer Teilnehmer[] } +/// Invitation issued by a Gemeinde Verantwortliche/r so new Gemeinde Teamer +/// can self-register a local account for one Gemeinde. A group link leaves +/// `email` null and may be redeemed up to `maxUses` times (null = unlimited); +/// a personal invite pins `email` and defaults to a single use. +model TeamerInvite { + id String @id @default(cuid()) + kcId String + gemeindeId String + token String @unique + email String? + maxUses Int? + usedCount Int @default(0) + expiresAt DateTime? + revokedAt DateTime? + createdByUserId String + createdAt DateTime @default(now()) + + kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) + gemeinde Gemeinde @relation(fields: [gemeindeId], references: [id], onDelete: Cascade) +} + /// A workshop election, scoped to a Kc; name carries a date key + "Teil". model Wahl { id String @id @default(cuid()) diff --git a/src/app.module.ts b/src/app.module.ts index 3ddb730..806bcf0 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,7 @@ import { PrismaModule } from './prisma/prisma.module'; import { AuthModule } from './auth/auth.module'; import { KcModule } from './kc/kc.module'; import { GemeindeModule } from './gemeinde/gemeinde.module'; +import { TeamerModule } from './teamer/teamer.module'; import { WahlModule } from './wahl/wahl.module'; import { FilesModule } from './files/files.module'; import { ChatModule } from './chat/chat.module'; @@ -25,6 +26,7 @@ import { SyncModule } from './sync/sync.module'; AuthModule, KcModule, GemeindeModule, + TeamerModule, WahlModule, FilesModule, ChatModule, diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index d4a1202..370d0d5 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,14 +1,32 @@ import { Body, Controller, Post } from '@nestjs/common'; import { GuestAuthService } from './guest-auth.service'; +import { TeamAuthService } from './team-auth.service'; import { CreateGuestDto } from './dto/create-guest.dto'; +import { TeamLoginDto } from './dto/team-login.dto'; +import { RegisterTeamerDto } from './dto/register-teamer.dto'; @Controller('auth') export class AuthController { - constructor(private readonly guestAuth: GuestAuthService) {} + constructor( + private readonly guestAuth: GuestAuthService, + private readonly teamAuth: TeamAuthService, + ) {} /// Redeems a KC invite code and registers a new temporary guest/Konfi account. @Post('guest') createGuest(@Body() dto: CreateGuestDto) { return this.guestAuth.createGuest(dto.inviteCode, dto.firstName, dto.lastName); } + + /// Password login for local Gemeinde Teamer accounts. + @Post('team-login') + teamLogin(@Body() dto: TeamLoginDto) { + return this.teamAuth.login(dto.email, dto.password); + } + + /// Self-registration for a Gemeinde Teamer via an invite token/link. + @Post('teamer/register') + registerTeamer(@Body() dto: RegisterTeamerDto) { + return this.teamAuth.registerFromInvite(dto); + } } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 706b234..20d2733 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -4,8 +4,10 @@ import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import { AuthController } from './auth.controller'; import { GuestAuthService } from './guest-auth.service'; +import { TeamAuthService } from './team-auth.service'; import { AuthentikStrategy } from './authentik.strategy'; import { GuestJwtStrategy } from './guest-jwt.strategy'; +import { TeamJwtStrategy } from './team-jwt.strategy'; import { TokenVerificationService } from './token-verification.service'; @Module({ @@ -20,7 +22,14 @@ import { TokenVerificationService } from './token-verification.service'; }), ], controllers: [AuthController], - providers: [GuestAuthService, AuthentikStrategy, GuestJwtStrategy, TokenVerificationService], - exports: [TokenVerificationService], + providers: [ + GuestAuthService, + TeamAuthService, + AuthentikStrategy, + GuestJwtStrategy, + TeamJwtStrategy, + TokenVerificationService, + ], + exports: [TokenVerificationService, TeamAuthService], }) export class AuthModule {} diff --git a/src/auth/authenticated-request.ts b/src/auth/authenticated-request.ts index aa4ed20..31e4ea0 100644 --- a/src/auth/authenticated-request.ts +++ b/src/auth/authenticated-request.ts @@ -8,10 +8,12 @@ export interface AuthenticatedMembership { role: Role; } -/// Shape attached to req.user by JwtStrategy after validating an access token. +/// Shape attached to req.user after validating an access token — by +/// AuthentikStrategy for Authentik-backed members, or by TeamJwtStrategy for +/// local Gemeinde Teamer (then `authentikSub` is null). export interface AuthenticatedUser { userId: string; - authentikSub: string; + authentikSub: string | null; email: string; memberships: AuthenticatedMembership[]; } diff --git a/src/auth/dto/register-teamer.dto.ts b/src/auth/dto/register-teamer.dto.ts new file mode 100644 index 0000000..994037d --- /dev/null +++ b/src/auth/dto/register-teamer.dto.ts @@ -0,0 +1,30 @@ +import { + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + MinLength, +} from 'class-validator'; + +export class RegisterTeamerDto { + @IsString() + @IsNotEmpty() + token!: string; + + @IsString() + @IsNotEmpty() + firstName!: string; + + @IsString() + @IsNotEmpty() + lastName!: string; + + @IsString() + @MinLength(8) + password!: string; + + /// Required for group-link invites; ignored/validated against a personal invite. + @IsOptional() + @IsEmail() + email?: string; +} diff --git a/src/auth/dto/team-login.dto.ts b/src/auth/dto/team-login.dto.ts new file mode 100644 index 0000000..e4f31af --- /dev/null +++ b/src/auth/dto/team-login.dto.ts @@ -0,0 +1,10 @@ +import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; + +export class TeamLoginDto { + @IsEmail() + email!: string; + + @IsString() + @IsNotEmpty() + password!: string; +} diff --git a/src/auth/team-auth.service.spec.ts b/src/auth/team-auth.service.spec.ts new file mode 100644 index 0000000..bff85e3 --- /dev/null +++ b/src/auth/team-auth.service.spec.ts @@ -0,0 +1,232 @@ +import { + ConflictException, + ForbiddenException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { Role } from '@prisma/client'; +import * as bcrypt from 'bcryptjs'; +import { TeamAuthService } from './team-auth.service'; + +/// Covers the branching in invite redemption and password login. Prisma and +/// SyncService are faked in memory; bcrypt/jsonwebtoken run for real. + +const SECRET = 'test-team-secret'; + +interface InviteRow { + id: string; + kcId: string; + gemeindeId: string; + token: string; + email: string | null; + maxUses: number | null; + usedCount: number; + expiresAt: Date | null; + revokedAt: Date | null; +} + +function makeService(seed: { + invites?: InviteRow[]; + users?: { id: string; email: string; passwordHash: string | null }[]; +}) { + const invites = [...(seed.invites ?? [])]; + const users = [...(seed.users ?? [])].map((u) => ({ + firstName: 'X', + lastName: 'Y', + authentikSub: null, + kcId: null, + createdAt: new Date(), + memberships: [] as unknown[], + ...u, + })); + + const prisma = { + user: { + findUnique: jest.fn(({ where }: { where: { email?: string; id?: string } }) => + Promise.resolve( + users.find( + (u) => + (where.email !== undefined && u.email === where.email) || + (where.id !== undefined && u.id === where.id), + ) ?? null, + ), + ), + findFirst: jest.fn(({ where }: { where: { id: string } }) => + Promise.resolve(users.find((u) => u.id === where.id && u.passwordHash) ?? null), + ), + create: jest.fn(({ data }: { data: Record }) => { + const row = { id: `u-${users.length + 1}`, memberships: [], ...data } as never; + users.push(row); + return Promise.resolve(row); + }), + }, + membership: { + create: jest.fn(({ data }: { data: Record }) => + Promise.resolve({ id: `m-1`, ...data }), + ), + }, + teamerInvite: { + findUnique: jest.fn(({ where }: { where: { token: string } }) => + Promise.resolve(invites.find((i) => i.token === where.token) ?? null), + ), + update: jest.fn(({ where, data }: { where: { id: string }; data: { usedCount: { increment: number } } }) => { + const inv = invites.find((i) => i.id === where.id)!; + inv.usedCount += data.usedCount.increment; + return Promise.resolve(inv); + }), + }, + }; + const sync = { capture: jest.fn().mockResolvedValue(undefined) }; + const config = { getOrThrow: jest.fn().mockReturnValue(SECRET) }; + + const service = new TeamAuthService(prisma as never, config as never, sync as never); + return { service, prisma, sync, users, invites }; +} + +function invite(overrides: Partial = {}): InviteRow { + return { + id: 'inv-1', + kcId: 'kc-1', + gemeindeId: 'gem-1', + token: 'tok-1', + email: null, + maxUses: null, + usedCount: 0, + expiresAt: null, + revokedAt: null, + ...overrides, + }; +} + +const base = { + token: 'tok-1', + firstName: 'Mara', + lastName: 'Klein', + password: 'supersecret', +}; + +describe('TeamAuthService.registerFromInvite', () => { + it('rejects an unknown token', async () => { + const { service } = makeService({ invites: [] }); + await expect( + service.registerFromInvite({ ...base, email: 'm@example.org' }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('rejects a revoked invite', async () => { + const { service } = makeService({ invites: [invite({ revokedAt: new Date() })] }); + await expect( + service.registerFromInvite({ ...base, email: 'm@example.org' }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('rejects an expired invite', async () => { + const { service } = makeService({ + invites: [invite({ expiresAt: new Date(Date.now() - 1000) })], + }); + await expect( + service.registerFromInvite({ ...base, email: 'm@example.org' }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('rejects an invite that is used up', async () => { + const { service } = makeService({ + invites: [invite({ maxUses: 2, usedCount: 2 })], + }); + await expect( + service.registerFromInvite({ ...base, email: 'm@example.org' }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('requires an email for a group-link invite', async () => { + const { service } = makeService({ invites: [invite({ email: null })] }); + await expect(service.registerFromInvite({ ...base })).rejects.toBeInstanceOf( + ConflictException, + ); + }); + + it('rejects an email that does not match a personal invite', async () => { + const { service } = makeService({ + invites: [invite({ email: 'pinned@example.org' })], + }); + await expect( + service.registerFromInvite({ ...base, email: 'other@example.org' }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('rejects when an account with that email already exists', async () => { + const { service } = makeService({ + invites: [invite()], + users: [{ id: 'u-x', email: 'm@example.org', passwordHash: 'h' }], + }); + await expect( + service.registerFromInvite({ ...base, email: 'm@example.org' }), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('creates a local Teamer + GEMEINDE_TEAMER membership and burns one use', async () => { + const { service, prisma, sync, invites } = makeService({ invites: [invite()] }); + const res = await service.registerFromInvite({ ...base, email: 'M@Example.org' }); + + expect(res.accessToken).toEqual(expect.any(String)); + expect(prisma.user.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + email: 'm@example.org', + kcId: 'kc-1', + passwordHash: expect.any(String), + }), + }); + const createdHash = prisma.user.create.mock.calls[0][0].data.passwordHash as string; + expect(await bcrypt.compare('supersecret', createdHash)).toBe(true); + expect(prisma.membership.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + kcId: 'kc-1', + gemeindeId: 'gem-1', + role: Role.GEMEINDE_TEAMER, + }), + }); + expect(invites[0].usedCount).toBe(1); + expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', expect.any(String), expect.anything()); + expect(sync.capture).toHaveBeenCalledWith('Membership', 'CREATE', expect.any(String), expect.anything()); + expect(sync.capture).toHaveBeenCalledWith('TeamerInvite', 'UPDATE', expect.any(String), expect.anything()); + }); +}); + +describe('TeamAuthService.login', () => { + it('rejects an unknown email', async () => { + const { service } = makeService({ users: [] }); + await expect(service.login('nobody@example.org', 'x')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + + it('rejects a user without a password hash (Authentik-only account)', async () => { + const { service } = makeService({ + users: [{ id: 'u-1', email: 'lt@example.org', passwordHash: null }], + }); + await expect(service.login('lt@example.org', 'x')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + + it('rejects a wrong password', async () => { + const { service } = makeService({ + users: [ + { id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) }, + ], + }); + await expect(service.login('t@example.org', 'wrong')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + + it('issues a token for correct credentials', async () => { + const { service } = makeService({ + users: [ + { id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) }, + ], + }); + const res = await service.login('T@example.org', 'right'); + expect(res.accessToken).toEqual(expect.any(String)); + }); +}); diff --git a/src/auth/team-auth.service.ts b/src/auth/team-auth.service.ts new file mode 100644 index 0000000..5ab4833 --- /dev/null +++ b/src/auth/team-auth.service.ts @@ -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('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 { + 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 { + 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, + })), + }; + } +} diff --git a/src/auth/team-jwt.strategy.ts b/src/auth/team-jwt.strategy.ts new file mode 100644 index 0000000..f6896a7 --- /dev/null +++ b/src/auth/team-jwt.strategy.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ConfigService } from '@nestjs/config'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { AuthenticatedUser } from './authenticated-request'; +import { TeamAuthService, TeamJwtPayload } from './team-auth.service'; + +/// Verifies the local JWT issued to Gemeinde Teamer by TeamAuthService and +/// resolves it to the same AuthenticatedUser shape as AuthentikStrategy, so +/// downstream RolesGuard / controllers treat both member kinds identically. +@Injectable() +export class TeamJwtStrategy extends PassportStrategy(Strategy, 'team') { + constructor( + config: ConfigService, + private readonly teamAuth: TeamAuthService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: config.getOrThrow('TEAM_JWT_SECRET'), + }); + } + + validate(payload: TeamJwtPayload): Promise { + return this.teamAuth.resolve(payload.sub); + } +} diff --git a/src/auth/token-verification.service.ts b/src/auth/token-verification.service.ts index 374725d..6823c52 100644 --- a/src/auth/token-verification.service.ts +++ b/src/auth/token-verification.service.ts @@ -6,6 +6,7 @@ import * as jwksRsa from 'jwks-rsa'; import { PrismaClient } from '../prisma/prisma.module'; import { AuthenticatedUser } from './authenticated-request'; import { GuestJwtPayload } from './guest-auth.service'; +import { TeamAuthService } from './team-auth.service'; /// Verifies raw bearer tokens outside the HTTP/passport pipeline, needed for /// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply. @@ -18,6 +19,7 @@ export class TokenVerificationService { private readonly config: ConfigService, private readonly prisma: PrismaClient, private readonly guestJwt: JwtService, + private readonly teamAuth: TeamAuthService, ) { this.issuerUrl = config.getOrThrow('AUTHENTIK_ISSUER_URL'); this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true }); @@ -61,12 +63,17 @@ export class TokenVerificationService { return this.guestJwt.verifyAsync(token); } - /// Tries Authentik first (team member), then falls back to a guest token. + /// Tries Authentik, then a local team (Teamer) token, then a guest token. async verifyEither(token: string): Promise< { kind: 'user'; user: AuthenticatedUser } | { kind: 'guest'; guest: GuestJwtPayload } > { try { return { kind: 'user', user: await this.verifyAuthentik(token) }; + } catch { + // not an Authentik token + } + try { + return { kind: 'user', user: await this.teamAuth.verify(token) }; } catch { return { kind: 'guest', guest: await this.verifyGuest(token) }; } diff --git a/src/chat/chat.controller.ts b/src/chat/chat.controller.ts index 75370b1..325068f 100644 --- a/src/chat/chat.controller.ts +++ b/src/chat/chat.controller.ts @@ -24,21 +24,22 @@ export class ChatController { return this.chat.createChannel(kcId, dto.type, dto.gemeindeId); } - /// Any two team members of the same KC can start a direct conversation. + /// Any two team members of the same KC can start a direct conversation + /// (Authentik-backed members and local Gemeinde Teamer alike). @Post('direct') - @UseGuards(AuthGuard('authentik')) + @UseGuards(AuthGuard(['authentik', 'team'])) createDirectChannel(@Body() dto: CreateDirectChannelDto, @Req() req: AuthenticatedRequest) { return this.chat.getOrCreateDirectChannel(dto.kcId, req.user!.userId, dto.otherUserId); } @Get(':kcId/channels') - @UseGuards(AuthGuard(['authentik', 'guest'])) + @UseGuards(AuthGuard(['authentik', 'team', 'guest'])) listChannels(@Param('kcId') kcId: string, @Req() req: ChatRequest) { return this.chat.listChannelsForCaller(kcId, resolveChatCaller(req.user!)); } @Get('channels/:channelId/messages') - @UseGuards(AuthGuard(['authentik', 'guest'])) + @UseGuards(AuthGuard(['authentik', 'team', 'guest'])) listMessages(@Param('channelId') channelId: string, @Req() req: ChatRequest) { return this.chat.listMessages(channelId, resolveChatCaller(req.user!)); } diff --git a/src/files/files.controller.ts b/src/files/files.controller.ts index c173666..b7bb5e3 100644 --- a/src/files/files.controller.ts +++ b/src/files/files.controller.ts @@ -47,7 +47,7 @@ export class FilesController { } @Get(':kcId') - @UseGuards(AuthGuard(['authentik', 'guest'])) + @UseGuards(AuthGuard(['authentik', 'team', 'guest'])) list(@Param('kcId') kcId: string, @Req() req: FileCallerRequest) { const allowed = isGuest(req.user) ? GUEST_ALLOWED_VISIBILITIES @@ -56,7 +56,7 @@ export class FilesController { } @Get('download/:fileId') - @UseGuards(AuthGuard(['authentik', 'guest'])) + @UseGuards(AuthGuard(['authentik', 'team', 'guest'])) async download( @Param('fileId') fileId: string, @Req() req: FileCallerRequest, diff --git a/src/sync/sync.service.ts b/src/sync/sync.service.ts index 094aab4..3d2746e 100644 --- a/src/sync/sync.service.ts +++ b/src/sync/sync.service.ts @@ -6,6 +6,9 @@ import { PrismaClient } from '../prisma/prisma.module'; const SYNCED_MODELS = [ 'Kc', 'Gemeinde', + 'User', + 'Membership', + 'TeamerInvite', 'GuestAccount', 'Wahl', 'Workshop', diff --git a/src/teamer/dto/create-teamer-invite.dto.ts b/src/teamer/dto/create-teamer-invite.dto.ts new file mode 100644 index 0000000..b1a331e --- /dev/null +++ b/src/teamer/dto/create-teamer-invite.dto.ts @@ -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; +} diff --git a/src/teamer/dto/create-teamer.dto.ts b/src/teamer/dto/create-teamer.dto.ts new file mode 100644 index 0000000..e5d4002 --- /dev/null +++ b/src/teamer/dto/create-teamer.dto.ts @@ -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; +} diff --git a/src/teamer/teamer.controller.ts b/src/teamer/teamer.controller.ts new file mode 100644 index 0000000..ba11723 --- /dev/null +++ b/src/teamer/teamer.controller.ts @@ -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); + } +} diff --git a/src/teamer/teamer.module.ts b/src/teamer/teamer.module.ts new file mode 100644 index 0000000..7706997 --- /dev/null +++ b/src/teamer/teamer.module.ts @@ -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 {} diff --git a/src/teamer/teamer.service.spec.ts b/src/teamer/teamer.service.spec.ts new file mode 100644 index 0000000..28a71d9 --- /dev/null +++ b/src/teamer/teamer.service.spec.ts @@ -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 = {}; + + 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 }) => { + 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 }) => { + 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 }) => + 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' }); + }); +}); diff --git a/src/teamer/teamer.service.ts b/src/teamer/teamer.service.ts new file mode 100644 index 0000000..e397106 --- /dev/null +++ b/src/teamer/teamer.service.ts @@ -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 { + 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 { + 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, + }; +}