Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e87fca4799 |
@@ -1,10 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
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
|
KC-APP-Server
|
||||||
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 Copyright (C) 2026 linus
|
KC-APP-Server 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,37 +1,2 @@
|
|||||||
# KC-App Backend
|
# KC-APP-Server
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"$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
@@ -1,80 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
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])
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { IsNotEmpty, IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateGuestDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
inviteCode!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
firstName!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
lastName!: string;
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
/// 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';
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { IsNotEmpty, IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateKcDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
name!: string;
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { KcService } from './kc.service';
|
|
||||||
import { KcController } from './kc.controller';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
providers: [KcService],
|
|
||||||
controllers: [KcController],
|
|
||||||
})
|
|
||||||
export class KcModule {}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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
@@ -1,11 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"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