feat: Wahl-Phasen, Verantwortliche-Invites, Auth fixes, GRUPPE chat #1
@@ -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)
|
# Secret used to sign guest/Konfi session tokens (local accounts only)
|
||||||
GUEST_JWT_SECRET="change-me"
|
GUEST_JWT_SECRET="change-me"
|
||||||
|
|
||||||
|
# Secret used to sign local Gemeinde Teamer session tokens (password login)
|
||||||
|
TEAM_JWT_SECRET="change-me-too"
|
||||||
|
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
# File storage: defaults to Nextcloud via WebDAV; set STORAGE_PROVIDER=s3 to
|
# File storage: defaults to Nextcloud via WebDAV; set STORAGE_PROVIDER=s3 to
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ architecture context).
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
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 generate
|
||||||
npx prisma migrate dev --name init # requires a running PostgreSQL instance
|
npx prisma migrate dev --name init # requires a running PostgreSQL instance
|
||||||
npm run start:dev
|
npm run start:dev
|
||||||
@@ -20,12 +20,20 @@ client's host - no separate web server is needed.
|
|||||||
|
|
||||||
## Auth model
|
## Auth model
|
||||||
|
|
||||||
- Team members (Leitungsteam, Gemeinde Verantwortliche, Gemeinde Teamer) are
|
- Leitungsteam and Gemeinde Verantwortliche are provisioned in Authentik
|
||||||
provisioned in Authentik; this API acts as an OIDC **resource server**,
|
(the "Konfi-Castle-ID"); this API acts as an OIDC **resource server**,
|
||||||
verifying access tokens against Authentik's JWKS (`AuthentikStrategy`) and
|
verifying access tokens against Authentik's JWKS (`AuthentikStrategy`) and
|
||||||
then resolving local `Membership` rows to determine role + KC/Gemeinde
|
then resolving local `Membership` rows to determine role + KC/Gemeinde
|
||||||
scope. Clients perform the actual Authorization Code + PKCE flow against
|
scope. Clients perform the actual Authorization Code + PKCE flow against
|
||||||
Authentik directly.
|
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
|
- Guests/Konfis get a temporary local account (first/last name required, no
|
||||||
Authentik) created via `POST /auth/guest` with a KC invite code, returning
|
Authentik) created via `POST /auth/guest` with a KC invite code, returning
|
||||||
a JWT signed with `GUEST_JWT_SECRET`.
|
a JWT signed with `GUEST_JWT_SECRET`.
|
||||||
@@ -33,13 +41,24 @@ client's host - no separate web server is needed.
|
|||||||
## Modules implemented so far
|
## Modules implemented so far
|
||||||
|
|
||||||
- `prisma/` — shared `PrismaClient` provider.
|
- `prisma/` — shared `PrismaClient` provider.
|
||||||
- `auth/` — Authentik resource-server strategy (`AuthGuard('authentik')`) +
|
- `auth/` — Authentik resource-server strategy (`AuthGuard('authentik')`),
|
||||||
guest invite-code login issuing a locally-signed JWT (`AuthGuard('guest')`).
|
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.
|
- `kc/` — KC (event) creation/listing, Leitungsteam-only.
|
||||||
- `gemeinde/` — Gemeinde (congregation) CRUD per KC (`POST /gemeinde`,
|
- `gemeinde/` — Gemeinde (congregation) CRUD per KC (`POST /gemeinde`,
|
||||||
`GET /gemeinde?kcId=`, `GET/PATCH/DELETE /gemeinde/:id`), Leitungsteam-only.
|
`GET /gemeinde?kcId=`, `GET/PATCH/DELETE /gemeinde/:id`), Leitungsteam-only.
|
||||||
Gemeinde Verantwortliche/Teamer get their own Gemeinde from their
|
Gemeinde Verantwortliche/Teamer get their own Gemeinde from their
|
||||||
`Membership`, not from this endpoint.
|
`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
|
- `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 →
|
||||||
@@ -76,5 +95,8 @@ client's host - no separate web server is needed.
|
|||||||
- `common/` — `Role` enum, `@Roles()` decorator, `RolesGuard` (KC-scoped,
|
- `common/` — `Role` enum, `@Roles()` decorator, `RolesGuard` (KC-scoped,
|
||||||
Leitungsteam roles are global across all KCs).
|
Leitungsteam roles are global across all KCs).
|
||||||
|
|
||||||
All planned backend phases are implemented; remaining work is the Flutter
|
All planned backend phases are implemented. `npm test` runs Jest unit tests
|
||||||
clients (see repo root README).
|
(`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).
|
||||||
|
|||||||
Generated
+18
@@ -21,6 +21,7 @@
|
|||||||
"@nestjs/serve-static": "^4.0.2",
|
"@nestjs/serve-static": "^4.0.2",
|
||||||
"@nestjs/websockets": "^10.4.15",
|
"@nestjs/websockets": "^10.4.15",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
"@nestjs/cli": "^10.4.9",
|
"@nestjs/cli": "^10.4.9",
|
||||||
"@nestjs/schematics": "^10.2.3",
|
"@nestjs/schematics": "^10.2.3",
|
||||||
"@nestjs/testing": "^10.4.15",
|
"@nestjs/testing": "^10.4.15",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/jsonwebtoken": "^9.0.7",
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
@@ -2776,6 +2778,13 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@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": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
@@ -3923,6 +3932,15 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"@nestjs/serve-static": "^4.0.2",
|
"@nestjs/serve-static": "^4.0.2",
|
||||||
"@nestjs/websockets": "^10.4.15",
|
"@nestjs/websockets": "^10.4.15",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"@nestjs/cli": "^10.4.9",
|
"@nestjs/cli": "^10.4.9",
|
||||||
"@nestjs/schematics": "^10.2.3",
|
"@nestjs/schematics": "^10.2.3",
|
||||||
"@nestjs/testing": "^10.4.15",
|
"@nestjs/testing": "^10.4.15",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/jsonwebtoken": "^9.0.7",
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
|
|||||||
+32
-2
@@ -22,6 +22,8 @@ model Kc {
|
|||||||
files File[]
|
files File[]
|
||||||
channels ChatChannel[]
|
channels ChatChannel[]
|
||||||
guests GuestAccount[]
|
guests GuestAccount[]
|
||||||
|
localUsers User[]
|
||||||
|
teamerInvites TeamerInvite[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A local congregation/community participating in one Kc.
|
/// A local congregation/community participating in one Kc.
|
||||||
@@ -34,6 +36,7 @@ model Gemeinde {
|
|||||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||||
memberships Membership[]
|
memberships Membership[]
|
||||||
guests GuestAccount[]
|
guests GuestAccount[]
|
||||||
|
teamerInvites TeamerInvite[]
|
||||||
|
|
||||||
@@unique([kcId, name])
|
@@unique([kcId, name])
|
||||||
}
|
}
|
||||||
@@ -44,15 +47,21 @@ enum Role {
|
|||||||
GEMEINDE_TEAMER
|
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 {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
authentikSub String @unique
|
authentikSub String? @unique
|
||||||
email String @unique
|
email String @unique
|
||||||
firstName String
|
firstName String
|
||||||
lastName String
|
lastName String
|
||||||
|
passwordHash String?
|
||||||
|
kcId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
kc Kc? @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||||
memberships Membership[]
|
memberships Membership[]
|
||||||
messages ChatMessage[]
|
messages ChatMessage[]
|
||||||
chatParticipations ChatParticipant[]
|
chatParticipations ChatParticipant[]
|
||||||
@@ -90,6 +99,27 @@ model GuestAccount {
|
|||||||
teilnehmer Teilnehmer[]
|
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".
|
/// A workshop election, scoped to a Kc; name carries a date key + "Teil".
|
||||||
model Wahl {
|
model Wahl {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { PrismaModule } from './prisma/prisma.module';
|
|||||||
import { AuthModule } from './auth/auth.module';
|
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 { 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';
|
||||||
@@ -25,6 +26,7 @@ import { SyncModule } from './sync/sync.module';
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
KcModule,
|
KcModule,
|
||||||
GemeindeModule,
|
GemeindeModule,
|
||||||
|
TeamerModule,
|
||||||
WahlModule,
|
WahlModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
ChatModule,
|
ChatModule,
|
||||||
|
|||||||
@@ -1,14 +1,32 @@
|
|||||||
import { Body, Controller, Post } from '@nestjs/common';
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
import { GuestAuthService } from './guest-auth.service';
|
import { GuestAuthService } from './guest-auth.service';
|
||||||
|
import { TeamAuthService } from './team-auth.service';
|
||||||
import { CreateGuestDto } from './dto/create-guest.dto';
|
import { CreateGuestDto } from './dto/create-guest.dto';
|
||||||
|
import { TeamLoginDto } from './dto/team-login.dto';
|
||||||
|
import { RegisterTeamerDto } from './dto/register-teamer.dto';
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
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.
|
/// Redeems a KC invite code and registers a new temporary guest/Konfi account.
|
||||||
@Post('guest')
|
@Post('guest')
|
||||||
createGuest(@Body() dto: CreateGuestDto) {
|
createGuest(@Body() dto: CreateGuestDto) {
|
||||||
return this.guestAuth.createGuest(dto.inviteCode, dto.firstName, dto.lastName);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -4,8 +4,10 @@ import { JwtModule } from '@nestjs/jwt';
|
|||||||
import { PassportModule } from '@nestjs/passport';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { GuestAuthService } from './guest-auth.service';
|
import { GuestAuthService } from './guest-auth.service';
|
||||||
|
import { TeamAuthService } from './team-auth.service';
|
||||||
import { AuthentikStrategy } from './authentik.strategy';
|
import { AuthentikStrategy } from './authentik.strategy';
|
||||||
import { GuestJwtStrategy } from './guest-jwt.strategy';
|
import { GuestJwtStrategy } from './guest-jwt.strategy';
|
||||||
|
import { TeamJwtStrategy } from './team-jwt.strategy';
|
||||||
import { TokenVerificationService } from './token-verification.service';
|
import { TokenVerificationService } from './token-verification.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -20,7 +22,14 @@ import { TokenVerificationService } from './token-verification.service';
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [GuestAuthService, AuthentikStrategy, GuestJwtStrategy, TokenVerificationService],
|
providers: [
|
||||||
exports: [TokenVerificationService],
|
GuestAuthService,
|
||||||
|
TeamAuthService,
|
||||||
|
AuthentikStrategy,
|
||||||
|
GuestJwtStrategy,
|
||||||
|
TeamJwtStrategy,
|
||||||
|
TokenVerificationService,
|
||||||
|
],
|
||||||
|
exports: [TokenVerificationService, TeamAuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ export interface AuthenticatedMembership {
|
|||||||
role: Role;
|
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 {
|
export interface AuthenticatedUser {
|
||||||
userId: string;
|
userId: string;
|
||||||
authentikSub: string;
|
authentikSub: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
memberships: AuthenticatedMembership[];
|
memberships: AuthenticatedMembership[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class TeamLoginDto {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
@@ -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<string, unknown> }) => {
|
||||||
|
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<string, unknown> }) =>
|
||||||
|
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> = {}): 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string>('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<AuthenticatedUser> {
|
||||||
|
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<AuthenticatedUser> {
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string>('TEAM_JWT_SECRET'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(payload: TeamJwtPayload): Promise<AuthenticatedUser> {
|
||||||
|
return this.teamAuth.resolve(payload.sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import * as jwksRsa from 'jwks-rsa';
|
|||||||
import { PrismaClient } from '../prisma/prisma.module';
|
import { PrismaClient } from '../prisma/prisma.module';
|
||||||
import { AuthenticatedUser } from './authenticated-request';
|
import { AuthenticatedUser } from './authenticated-request';
|
||||||
import { GuestJwtPayload } from './guest-auth.service';
|
import { GuestJwtPayload } from './guest-auth.service';
|
||||||
|
import { TeamAuthService } from './team-auth.service';
|
||||||
|
|
||||||
/// Verifies raw bearer tokens outside the HTTP/passport pipeline, needed for
|
/// Verifies raw bearer tokens outside the HTTP/passport pipeline, needed for
|
||||||
/// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply.
|
/// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply.
|
||||||
@@ -18,6 +19,7 @@ export class TokenVerificationService {
|
|||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly prisma: PrismaClient,
|
private readonly prisma: PrismaClient,
|
||||||
private readonly guestJwt: JwtService,
|
private readonly guestJwt: JwtService,
|
||||||
|
private readonly teamAuth: TeamAuthService,
|
||||||
) {
|
) {
|
||||||
this.issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
|
this.issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
|
||||||
this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true });
|
this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true });
|
||||||
@@ -61,12 +63,17 @@ export class TokenVerificationService {
|
|||||||
return this.guestJwt.verifyAsync<GuestJwtPayload>(token);
|
return this.guestJwt.verifyAsync<GuestJwtPayload>(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<
|
async verifyEither(token: string): Promise<
|
||||||
{ kind: 'user'; user: AuthenticatedUser } | { kind: 'guest'; guest: GuestJwtPayload }
|
{ kind: 'user'; user: AuthenticatedUser } | { kind: 'guest'; guest: GuestJwtPayload }
|
||||||
> {
|
> {
|
||||||
try {
|
try {
|
||||||
return { kind: 'user', user: await this.verifyAuthentik(token) };
|
return { kind: 'user', user: await this.verifyAuthentik(token) };
|
||||||
|
} catch {
|
||||||
|
// not an Authentik token
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return { kind: 'user', user: await this.teamAuth.verify(token) };
|
||||||
} catch {
|
} catch {
|
||||||
return { kind: 'guest', guest: await this.verifyGuest(token) };
|
return { kind: 'guest', guest: await this.verifyGuest(token) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,21 +24,22 @@ export class ChatController {
|
|||||||
return this.chat.createChannel(kcId, dto.type, dto.gemeindeId);
|
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')
|
@Post('direct')
|
||||||
@UseGuards(AuthGuard('authentik'))
|
@UseGuards(AuthGuard(['authentik', 'team']))
|
||||||
createDirectChannel(@Body() dto: CreateDirectChannelDto, @Req() req: AuthenticatedRequest) {
|
createDirectChannel(@Body() dto: CreateDirectChannelDto, @Req() req: AuthenticatedRequest) {
|
||||||
return this.chat.getOrCreateDirectChannel(dto.kcId, req.user!.userId, dto.otherUserId);
|
return this.chat.getOrCreateDirectChannel(dto.kcId, req.user!.userId, dto.otherUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':kcId/channels')
|
@Get(':kcId/channels')
|
||||||
@UseGuards(AuthGuard(['authentik', 'guest']))
|
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
|
||||||
listChannels(@Param('kcId') kcId: string, @Req() req: ChatRequest) {
|
listChannels(@Param('kcId') kcId: string, @Req() req: ChatRequest) {
|
||||||
return this.chat.listChannelsForCaller(kcId, resolveChatCaller(req.user!));
|
return this.chat.listChannelsForCaller(kcId, resolveChatCaller(req.user!));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('channels/:channelId/messages')
|
@Get('channels/:channelId/messages')
|
||||||
@UseGuards(AuthGuard(['authentik', 'guest']))
|
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
|
||||||
listMessages(@Param('channelId') channelId: string, @Req() req: ChatRequest) {
|
listMessages(@Param('channelId') channelId: string, @Req() req: ChatRequest) {
|
||||||
return this.chat.listMessages(channelId, resolveChatCaller(req.user!));
|
return this.chat.listMessages(channelId, resolveChatCaller(req.user!));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export class FilesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':kcId')
|
@Get(':kcId')
|
||||||
@UseGuards(AuthGuard(['authentik', 'guest']))
|
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
|
||||||
list(@Param('kcId') kcId: string, @Req() req: FileCallerRequest) {
|
list(@Param('kcId') kcId: string, @Req() req: FileCallerRequest) {
|
||||||
const allowed = isGuest(req.user)
|
const allowed = isGuest(req.user)
|
||||||
? GUEST_ALLOWED_VISIBILITIES
|
? GUEST_ALLOWED_VISIBILITIES
|
||||||
@@ -56,7 +56,7 @@ export class FilesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('download/:fileId')
|
@Get('download/:fileId')
|
||||||
@UseGuards(AuthGuard(['authentik', 'guest']))
|
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
|
||||||
async download(
|
async download(
|
||||||
@Param('fileId') fileId: string,
|
@Param('fileId') fileId: string,
|
||||||
@Req() req: FileCallerRequest,
|
@Req() req: FileCallerRequest,
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { PrismaClient } from '../prisma/prisma.module';
|
|||||||
const SYNCED_MODELS = [
|
const SYNCED_MODELS = [
|
||||||
'Kc',
|
'Kc',
|
||||||
'Gemeinde',
|
'Gemeinde',
|
||||||
|
'User',
|
||||||
|
'Membership',
|
||||||
|
'TeamerInvite',
|
||||||
'GuestAccount',
|
'GuestAccount',
|
||||||
'Wahl',
|
'Wahl',
|
||||||
'Workshop',
|
'Workshop',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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