Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
327ce43404 | ||
|
|
60ee8111af |
@@ -0,0 +1,10 @@
|
|||||||
|
# Postgres connection used by Prisma
|
||||||
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/kcapp?schema=public"
|
||||||
|
|
||||||
|
# Authentik OIDC issuer, e.g. https://auth.example.org/application/o/kc-app/
|
||||||
|
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"
|
||||||
|
|
||||||
|
PORT=3000
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
@@ -208,7 +208,7 @@ If you develop a new program, and you want it to be of the greatest possible use
|
|||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
KC-APP-Server
|
KC-APP
|
||||||
Copyright (C) 2026 linus
|
Copyright (C) 2026 linus
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
@@ -221,7 +221,7 @@ Also add information on how to contact you by electronic and paper mail.
|
|||||||
|
|
||||||
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
KC-APP-Server Copyright (C) 2026 linus
|
KC-APP Copyright (C) 2026 linus
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,37 @@
|
|||||||
# KC-APP-Server
|
# KC-App Backend
|
||||||
|
|
||||||
|
NestJS API for the KC-App platform (see repo root README + plan for
|
||||||
|
architecture context).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .env.example .env # then fill in DATABASE_URL / AUTHENTIK_ISSUER_URL / GUEST_JWT_SECRET
|
||||||
|
npx prisma generate
|
||||||
|
npx prisma migrate dev --name init # requires a running PostgreSQL instance
|
||||||
|
npm run start:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth model
|
||||||
|
|
||||||
|
- Team members (Leitungsteam, Gemeinde Verantwortliche, Gemeinde Teamer) are
|
||||||
|
provisioned in Authentik; 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.
|
||||||
|
- 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`.
|
||||||
|
|
||||||
|
## Modules implemented so far
|
||||||
|
|
||||||
|
- `prisma/` — shared `PrismaClient` provider.
|
||||||
|
- `auth/` — Authentik resource-server strategy + guest invite-code login.
|
||||||
|
- `kc/` — KC (event) creation/listing, Leitungsteam-only.
|
||||||
|
- `common/` — `Role` enum, `@Roles()` decorator, `RolesGuard` (KC-scoped,
|
||||||
|
Leitungsteam roles are global across all KCs).
|
||||||
|
|
||||||
|
Not yet implemented: Wahl/Workshop/Zuteilung engine, file sharing, chat
|
||||||
|
realtime gateway, local/cloud sync engine.
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10215
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "KC-App backend (NestJS)",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate dev"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^10.4.15",
|
||||||
|
"@nestjs/config": "^3.3.0",
|
||||||
|
"@nestjs/core": "^10.4.15",
|
||||||
|
"@nestjs/jwt": "^10.2.0",
|
||||||
|
"@nestjs/passport": "^10.0.3",
|
||||||
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
|
"@nestjs/platform-ws": "^10.4.15",
|
||||||
|
"@nestjs/websockets": "^10.4.15",
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"jwks-rsa": "^3.1.0",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.4.9",
|
||||||
|
"@nestjs/schematics": "^10.2.3",
|
||||||
|
"@nestjs/testing": "^10.4.15",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/node": "^20.17.9",
|
||||||
|
"@types/passport": "^1.0.17",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"@types/ws": "^8.5.13",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||||
|
"@typescript-eslint/parser": "^7.18.0",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^6.3.4",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-loader": "^9.5.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Konfi-Castle event; the top-level tenant. One instance manages many KCs.
|
||||||
|
model Kc {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
inviteCode String @unique
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
gemeinden Gemeinde[]
|
||||||
|
memberships Membership[]
|
||||||
|
wahlen Wahl[]
|
||||||
|
files File[]
|
||||||
|
channels ChatChannel[]
|
||||||
|
guests GuestAccount[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A local congregation/community participating in one Kc.
|
||||||
|
model Gemeinde {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
kcId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||||
|
memberships Membership[]
|
||||||
|
guests GuestAccount[]
|
||||||
|
|
||||||
|
@@unique([kcId, name])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
LEITUNGSTEAM
|
||||||
|
GEMEINDE_VERANTWORTLICHER
|
||||||
|
GEMEINDE_TEAMER
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authentik-backed user (team member with elevated rights).
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
authentikSub String @unique
|
||||||
|
email String @unique
|
||||||
|
firstName String
|
||||||
|
lastName String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
memberships Membership[]
|
||||||
|
messages ChatMessage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scopes a User's role to a specific Kc (and Gemeinde, if applicable).
|
||||||
|
/// LEITUNGSTEAM memberships apply to all Kcs implicitly and omit gemeindeId.
|
||||||
|
model Membership {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
kcId String
|
||||||
|
gemeindeId String?
|
||||||
|
role Role
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||||
|
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, kcId, gemeindeId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local, non-Authentik account for Konfis/guests, scoped to one Kc/event.
|
||||||
|
model GuestAccount {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
kcId String
|
||||||
|
gemeindeId String?
|
||||||
|
firstName String
|
||||||
|
lastName String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||||
|
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
|
||||||
|
messages ChatMessage[]
|
||||||
|
teilnehmer Teilnehmer[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A workshop election, scoped to a Kc; name carries a date key + "Teil".
|
||||||
|
model Wahl {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
kcId String
|
||||||
|
name String
|
||||||
|
datumsSchluessel String
|
||||||
|
teil String
|
||||||
|
isOpen Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||||
|
workshops Workshop[]
|
||||||
|
teilnehmer Teilnehmer[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Workshop {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
wahlId String
|
||||||
|
name String
|
||||||
|
kapazitaet Int
|
||||||
|
|
||||||
|
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
|
||||||
|
zuteilungen Zuteilung[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A participant's submitted choices for a Wahl.
|
||||||
|
model Teilnehmer {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
wahlId String
|
||||||
|
guestAccountId String
|
||||||
|
prioritaeten Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
|
||||||
|
guestAccount GuestAccount @relation(fields: [guestAccountId], references: [id], onDelete: Cascade)
|
||||||
|
zuteilung Zuteilung?
|
||||||
|
|
||||||
|
@@unique([wahlId, guestAccountId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of the assignment algorithm (or a manual force-assignment) for one Teilnehmer.
|
||||||
|
model Zuteilung {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
teilnehmerId String @unique
|
||||||
|
workshopId String
|
||||||
|
isForced Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
teilnehmer Teilnehmer @relation(fields: [teilnehmerId], references: [id], onDelete: Cascade)
|
||||||
|
workshop Workshop @relation(fields: [workshopId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FileVisibility {
|
||||||
|
ALLE
|
||||||
|
ALLE_AUSSER_KONFIS
|
||||||
|
NUR_LT
|
||||||
|
}
|
||||||
|
|
||||||
|
model File {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
kcId String
|
||||||
|
storageKey String
|
||||||
|
filename String
|
||||||
|
visibility FileVisibility
|
||||||
|
uploadedById String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChatChannelType {
|
||||||
|
GEMEINDE_GRUPPE
|
||||||
|
DIREKT
|
||||||
|
LT_UEBERGREIFEND
|
||||||
|
BROADCAST
|
||||||
|
}
|
||||||
|
|
||||||
|
model ChatChannel {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
kcId String
|
||||||
|
type ChatChannelType
|
||||||
|
gemeindeId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||||
|
messages ChatMessage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model ChatMessage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
channelId String
|
||||||
|
senderUserId String?
|
||||||
|
senderGuestId String?
|
||||||
|
body String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
||||||
|
senderUser User? @relation(fields: [senderUserId], references: [id])
|
||||||
|
senderGuest GuestAccount? @relation(fields: [senderGuestId], references: [id])
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { KcModule } from './kc/kc.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
|
PrismaModule,
|
||||||
|
AuthModule,
|
||||||
|
KcModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
|
import { GuestAuthService } from './guest-auth.service';
|
||||||
|
import { CreateGuestDto } from './dto/create-guest.dto';
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly guestAuth: GuestAuthService) {}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { GuestAuthService } from './guest-auth.service';
|
||||||
|
import { AuthentikStrategy } from './authentik.strategy';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
PassportModule,
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: config.getOrThrow<string>('GUEST_JWT_SECRET'),
|
||||||
|
signOptions: { expiresIn: '12h' },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [GuestAuthService, AuthentikStrategy],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Request } from 'express';
|
||||||
|
import { Role } from '../common/role.enum';
|
||||||
|
|
||||||
|
export interface AuthenticatedMembership {
|
||||||
|
kcId: string;
|
||||||
|
gemeindeId: string | null;
|
||||||
|
role: Role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shape attached to req.user by JwtStrategy after validating an access token.
|
||||||
|
export interface AuthenticatedUser {
|
||||||
|
userId: string;
|
||||||
|
authentikSub: string;
|
||||||
|
email: string;
|
||||||
|
memberships: AuthenticatedMembership[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthenticatedRequest extends Request {
|
||||||
|
user?: AuthenticatedUser;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Strategy } from 'passport-jwt';
|
||||||
|
import * as jwksRsa from 'jwks-rsa';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { PrismaClient } from '../prisma/prisma.module';
|
||||||
|
import { AuthenticatedUser } from './authenticated-request';
|
||||||
|
|
||||||
|
interface AuthentikJwtPayload {
|
||||||
|
sub: string;
|
||||||
|
email: string;
|
||||||
|
given_name?: string;
|
||||||
|
family_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates access tokens issued by Authentik (resource-server pattern):
|
||||||
|
/// signature is checked against Authentik's JWKS, then the local Membership
|
||||||
|
/// table decides what the user may do. Authentik itself is only the identity
|
||||||
|
/// source, never asked for authorization here.
|
||||||
|
@Injectable()
|
||||||
|
export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
|
||||||
|
constructor(
|
||||||
|
config: ConfigService,
|
||||||
|
private readonly prisma: PrismaClient,
|
||||||
|
) {
|
||||||
|
const issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
|
||||||
|
super({
|
||||||
|
jwtFromRequest: (req: Request) =>
|
||||||
|
req.headers.authorization?.startsWith('Bearer ')
|
||||||
|
? req.headers.authorization.slice('Bearer '.length)
|
||||||
|
: null,
|
||||||
|
secretOrKeyProvider: jwksRsa.passportJwtSecret({
|
||||||
|
jwksUri: `${issuerUrl}/jwks/`,
|
||||||
|
cache: true,
|
||||||
|
rateLimit: true,
|
||||||
|
}),
|
||||||
|
issuer: issuerUrl,
|
||||||
|
algorithms: ['RS256'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { authentikSub: payload.sub },
|
||||||
|
include: { memberships: true },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('User not provisioned locally yet');
|
||||||
|
}
|
||||||
|
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,15 @@
|
|||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateGuestDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
inviteCode!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
firstName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
lastName!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { PrismaClient } from '../prisma/prisma.module';
|
||||||
|
|
||||||
|
export interface GuestJwtPayload {
|
||||||
|
guestId: string;
|
||||||
|
kcId: string;
|
||||||
|
gemeindeId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Guest/Konfi accounts are local to this server (never Authentik-backed),
|
||||||
|
/// created via a KC invite code, and scoped to that single KC.
|
||||||
|
@Injectable()
|
||||||
|
export class GuestAuthService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaClient,
|
||||||
|
private readonly jwt: JwtService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createGuest(
|
||||||
|
inviteCode: string,
|
||||||
|
firstName: string,
|
||||||
|
lastName: string,
|
||||||
|
): Promise<{ accessToken: string }> {
|
||||||
|
const kc = await this.prisma.kc.findUnique({ where: { inviteCode } });
|
||||||
|
if (!kc || !kc.isActive) {
|
||||||
|
throw new NotFoundException('Unknown or inactive KC invite code');
|
||||||
|
}
|
||||||
|
|
||||||
|
const guest = await this.prisma.guestAccount.create({
|
||||||
|
data: { kcId: kc.id, firstName, lastName },
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload: GuestJwtPayload = {
|
||||||
|
guestId: guest.id,
|
||||||
|
kcId: kc.id,
|
||||||
|
gemeindeId: guest.gemeindeId,
|
||||||
|
};
|
||||||
|
return { accessToken: await this.jwt.signAsync(payload) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/// Re-exported from the Prisma client so guards and strategies share one
|
||||||
|
/// enum type with the database schema. Guests are not part of this enum
|
||||||
|
/// since they authenticate separately and never hold elevated rights.
|
||||||
|
export { Role } from '@prisma/client';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { Role } from './role.enum';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
|
||||||
|
/// Marks a route as requiring at least one of the given roles (scope-checked by RolesGuard).
|
||||||
|
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { Role } from './role.enum';
|
||||||
|
import { ROLES_KEY } from './roles.decorator';
|
||||||
|
import { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||||
|
|
||||||
|
/// Checks the caller holds one of the required roles, scoped to the KC in the
|
||||||
|
/// request (route param `kcId`, falling back to body.kcId). LEITUNGSTEAM
|
||||||
|
/// memberships are global and satisfy any KC scope.
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private readonly reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (!requiredRoles || requiredRoles.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||||
|
const user = request.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new ForbiddenException('Not authenticated');
|
||||||
|
}
|
||||||
|
|
||||||
|
const kcId = request.params?.kcId ?? request.body?.kcId;
|
||||||
|
const hasRole = user.memberships.some((membership) => {
|
||||||
|
if (!requiredRoles.includes(membership.role)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (membership.role === Role.LEITUNGSTEAM) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return kcId ? membership.kcId === kcId : true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasRole) {
|
||||||
|
throw new ForbiddenException('Insufficient role for this KC');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateKcDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { KcService } from './kc.service';
|
||||||
|
import { CreateKcDto } from './dto/create-kc.dto';
|
||||||
|
import { Roles } from '../common/roles.decorator';
|
||||||
|
import { RolesGuard } from '../common/roles.guard';
|
||||||
|
import { Role } from '../common/role.enum';
|
||||||
|
|
||||||
|
@Controller('kc')
|
||||||
|
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
||||||
|
export class KcController {
|
||||||
|
constructor(private readonly kc: KcService) {}
|
||||||
|
|
||||||
|
/// Only the Leitungsteam may create new KC events.
|
||||||
|
@Post()
|
||||||
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
|
create(@Body() dto: CreateKcDto) {
|
||||||
|
return this.kc.createKc(dto.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
|
list() {
|
||||||
|
return this.kc.listKcs();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { KcService } from './kc.service';
|
||||||
|
import { KcController } from './kc.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [KcService],
|
||||||
|
controllers: [KcController],
|
||||||
|
})
|
||||||
|
export class KcModule {}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { PrismaClient } from '../prisma/prisma.module';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class KcService {
|
||||||
|
constructor(private readonly prisma: PrismaClient) {}
|
||||||
|
|
||||||
|
createKc(name: string) {
|
||||||
|
return this.prisma.kc.create({
|
||||||
|
data: { name, inviteCode: randomBytes(6).toString('hex') },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
listKcs() {
|
||||||
|
return this.prisma.kc.findMany();
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||||
|
app.enableCors();
|
||||||
|
await app.listen(process.env.PORT ?? 3000);
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
/// Shared Prisma connection; injected wherever DB access is needed.
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: PrismaClient,
|
||||||
|
useFactory: () => new PrismaClient(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [PrismaClient],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
|
|
||||||
|
export { PrismaClient };
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2021",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictBindCallApply": false,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user