dev env setup

This commit is contained in:
tw-blue
2025-12-06 21:27:29 +01:00
parent 987a380758
commit bc9f0f9f3b
38 changed files with 5953 additions and 733 deletions
+2
View File
@@ -3,3 +3,5 @@ generated/
/prisma/src/generated/prisma /prisma/src/generated/prisma
.env .env
keycloak/realm-export
+9 -1
View File
@@ -2,5 +2,13 @@
"tabWidth": 4, "tabWidth": 4,
"arrowParens": "avoid", "arrowParens": "avoid",
"bracketSameLine": true, "bracketSameLine": true,
"semi": true "semi": true,
"overrides": [
{
"files": "*.yml",
"options": {
"tabWidth": 2
}
}
]
} }
+48
View File
@@ -0,0 +1,48 @@
services:
postgres:
build:
context: .
dockerfile: ./postgres.DOCKERFILE
container_name: dev-postgres
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: devpassword
POSTGRES_INITDB_ARGS: "--locale=de_DE.utf8 --lc-collate=de_DE.utf8 --lc-ctype=de_DE.utf8"
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql
- ./postgres/initdb:/docker-entrypoint-initdb.d/
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
keycloak:
image: quay.io/keycloak/keycloak:26.4
container_name: dev-keycloak
command:
- start-dev
- --import-realm
environment:
KC_HOSTNAME: localhost
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: devpassword
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
volumes:
- ./keycloak/realm-export:/opt/keycloak/data/import
- keycloak_data:/opt/keycloak/data
volumes:
postgres_data:
keycloak_data:
+8
View File
@@ -0,0 +1,8 @@
FROM postgres:18
RUN apt-get update \
&& apt-get install -y locales \
&& sed -i '/de_DE.UTF-8/s/^# //g' /etc/locale.gen \
&& locale-gen
ENV LANG=de_DE.utf8
+9
View File
@@ -0,0 +1,9 @@
CREATE ROLE webapi WITH
LOGIN
NOSUPERUSER
INHERIT
NOCREATEDB
NOCREATEROLE
NOREPLICATION
NOBYPASSRLS
PASSWORD 'devpassword';
@@ -0,0 +1,23 @@
CREATE ROLE keycloak WITH
LOGIN
NOSUPERUSER
INHERIT
NOCREATEDB
NOCREATEROLE
NOREPLICATION
NOBYPASSRLS
PASSWORD 'devpassword';
CREATE DATABASE keycloak
WITH
OWNER = keycloak
ENCODING = 'UTF8'
TABLESPACE = pg_default
CONNECTION LIMIT = -1
IS_TEMPLATE = False;
-- TODO
-- Replace Client Secret in realm-export.json
-- replace Frontend redirect URLS in realm-export.json
-- SET NODE_ENV=production when running on server
-- SET DEFAULT_PERSISSIONS for webapi user
@@ -0,0 +1,9 @@
CREATE ROLE prisma WITH
LOGIN
NOSUPERUSER
INHERIT
CREATEDB
NOCREATEROLE
NOREPLICATION
NOBYPASSRLS
PASSWORD 'devpassword';
+16 -10
View File
@@ -3,29 +3,35 @@
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"start": "node dist/index.js", "start": "node dist/index.js",
"build": "tsc -p ." "build": "tsc -p .",
"dev": "run-script-os",
"dev:win32": "wsl -- bash -ic 'pnpm run dev:linux'",
"dev:linux": "./scripts/dev.sh"
}, },
"type": "module", "type": "module",
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a", "packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a",
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"@types/connect-pg-simple": "^7.0.3", "@types/connect-pg-simple": "^7.0.3",
"@types/express": "^5.0.5", "@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/express-session": "^1.18.2", "@types/express-session": "^1.18.2",
"@types/node": "^24.9.2", "@types/node": "^24.10.1",
"@types/pg": "^8.15.6", "@types/pg": "^8.15.6",
"prisma": "^7.0.1", "dotenv": "^17.2.3",
"tsx": "^4.20.6", "prisma": "^7.1.0",
"run-script-os": "^1.1.6",
"tsx": "^4.21.0",
"typescript": "~5.9.3" "typescript": "~5.9.3"
}, },
"dependencies": { "dependencies": {
"@keycloak/keycloak-admin-client": "^26.4.4", "@keycloak/keycloak-admin-client": "^26.4.7",
"@prisma/adapter-pg": "^7.0.1", "@prisma/adapter-pg": "^7.1.0",
"@prisma/client": "^7.0.1", "@prisma/client": "^7.1.0",
"@tsoa/runtime": "^6.6.0", "@tsoa/runtime": "^6.6.0",
"connect-pg-simple": "^10.0.0", "connect-pg-simple": "^10.0.0",
"dotenv": "^17.2.3", "cors": "^2.8.5",
"express": "^5.1.0", "express": "^5.2.1",
"express-session": "^1.18.2", "express-session": "^1.18.2",
"keycloak-connect": "^26.1.1", "keycloak-connect": "^26.1.1",
"pg": "^8.16.3", "pg": "^8.16.3",
+20 -12
View File
@@ -8,19 +8,27 @@ datasource db {
} }
model Member { model Member {
id String @db.Uuid @id @default(dbgenerated("uuidv7()")) id String @id @default(dbgenerated("uuidv7()")) @db.Uuid
vorname String? @db.VarChar vorname String? @db.VarChar
nachname String? @db.VarChar nachname String? @db.VarChar
geburtsdatum DateTime? @db.Date geburtsdatum DateTime? @db.Date
geburtsort String? @db.VarChar geburtsort String? @db.VarChar
geschlecht String? @db.VarChar geschlecht String? @db.VarChar
passnummer String? @db.VarChar passnummer String? @db.VarChar
uvertrag Boolean? @db.Boolean uvertrag Boolean?
verein Boolean? @db.Boolean verein Boolean?
graduierung String? @db.VarChar graduierung String? @db.VarChar
letztePruefung DateTime? @db.Date letztePruefung DateTime? @db.Date
kontakt String? @db.VarChar kontakt String? @db.VarChar
kuendigungzum DateTime? @db.Date kuendigungzum DateTime? @db.Date
}
model session {
sid String @id @db.VarChar
sess Json @db.Json
expire DateTime @db.Timestamp(6)
@@index([expire], map: "IDX_session_expire")
} }
enum Topic { enum Topic {
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
echo "Starting development environment..."
pnpm -v
@@ -17,7 +17,9 @@ export class HealthCheckController extends Controller {
@Get("ok") @Get("ok")
@SuccessResponse("200", "OK") @SuccessResponse("200", "OK")
public async getOk() {} public async getOk() {
return "ok";
}
@Get("auth") @Get("auth")
@Security(KC_SECURITY_NAME) @Security(KC_SECURITY_NAME)
@@ -25,6 +27,7 @@ export class HealthCheckController extends Controller {
public async getAuth() {} public async getAuth() {}
@Post("testmessage") @Post("testmessage")
@SuccessResponse("200", "OK")
public async sendTestMessage() { public async sendTestMessage() {
await this.messaging.sendTestMessage(); await this.messaging.sendTestMessage();
} }
@@ -6,11 +6,13 @@ import {
Path, Path,
Post, Post,
Put, Put,
Request,
Response, Response,
Route, Route,
Security, Security,
SuccessResponse, SuccessResponse,
} from "@tsoa/runtime"; } from "@tsoa/runtime";
import express from "express";
import { inject } from "../infrastructure/di/index.js"; import { inject } from "../infrastructure/di/index.js";
import { ErrorStates } from "../infrastructure/errorStates.js"; import { ErrorStates } from "../infrastructure/errorStates.js";
import { Claim } from "../models/claim.js"; import { Claim } from "../models/claim.js";
@@ -22,19 +24,27 @@ import { MembersService } from "../services/membersService.js";
export class MembersContoller extends Controller { export class MembersContoller extends Controller {
private readonly membersService = inject(MembersService); private readonly membersService = inject(MembersService);
@Security(KC_SECURITY_NAME, [Claim.MemberAdmin]) @Security(KC_SECURITY_NAME, [Claim.MemberAdmin])
@SuccessResponse("200", "OK")
@Get() @Get()
public async getMembers(): Promise<Member[]> { public async getMembers(
return await this.membersService.getMembers(); @Request() request: express.Request,
): Promise<Member[]> {
return await this.membersService.getMembers(request);
} }
@Security(KC_SECURITY_NAME, [Claim.MemberAdmin]) @Security(KC_SECURITY_NAME, [Claim.MemberAdmin])
@SuccessResponse("200", "OK") @SuccessResponse("200", "OK")
@Response(404, "Member not found") @Response(404, "Member not found")
@Get("{memberId}") @Get("{memberId}")
public async getMember(@Path() memberId: string): Promise<Member> { public async getMember(
@Path() memberId: string,
@Request() request: express.Request,
): Promise<Member> {
try { try {
const foundMember = const foundMember = await this.membersService.getMemberById(
await this.membersService.getMemberById(memberId); memberId,
request,
);
return foundMember; return foundMember;
} catch (error) { } catch (error) {
if ( if (
@@ -53,8 +63,9 @@ export class MembersContoller extends Controller {
@Post() @Post()
public async createMember( public async createMember(
@Body() createArgs: MemberCreateArgs, @Body() createArgs: MemberCreateArgs,
@Request() request: express.Request,
): Promise<Member> { ): Promise<Member> {
return await this.membersService.createMember(createArgs); return await this.membersService.createMember(createArgs, request);
} }
@Security(KC_SECURITY_NAME, [Claim.MemberAdmin]) @Security(KC_SECURITY_NAME, [Claim.MemberAdmin])
@@ -62,9 +73,12 @@ export class MembersContoller extends Controller {
@Response(400, "Bad Request") @Response(400, "Bad Request")
@Response(404, "Member not found") @Response(404, "Member not found")
@Put() @Put()
public async updateMember(@Body() member: Member): Promise<void> { public async updateMember(
@Body() member: Member,
@Request() request: express.Request,
): Promise<void> {
try { try {
await this.membersService.updateMember(member); await this.membersService.updateMember(member, request);
} catch (error) { } catch (error) {
if ( if (
error instanceof Error && error instanceof Error &&
@@ -83,8 +97,13 @@ export class MembersContoller extends Controller {
@Delete("{memberId}") @Delete("{memberId}")
public async scheduleDeletion( public async scheduleDeletion(
@Path() memberId: string, @Path() memberId: string,
@Body() cancelDate: Date @Body() cancelDate: Date,
){ @Request() request: express.Request,
await this.membersService.scheduleDeletion(memberId, cancelDate); ) {
await this.membersService.scheduleDeletion(
memberId,
cancelDate,
request,
);
} }
} }
+15 -14
View File
@@ -1,5 +1,4 @@
import { import {
Body,
BodyProp, BodyProp,
Controller, Controller,
Get, Get,
@@ -8,25 +7,27 @@ import {
Query, Query,
Route, Route,
Security, Security,
SuccessResponse,
} from "@tsoa/runtime"; } from "@tsoa/runtime";
import { inject } from "../infrastructure/di"; import { inject } from "../infrastructure/di/index.js";
import { Claim } from "../models/claim"; import { Claim } from "../models/claim.js";
import { Topic } from "../models/topic"; import { type User } from "../models/user.js";
import { User } from "../models/user";
import { KC_SECURITY_NAME } from "../services/keycloak/user/index.js"; import { KC_SECURITY_NAME } from "../services/keycloak/user/index.js";
import { UserService } from "../services/userService"; import { UserService } from "../services/userService.js";
@Route("users") @Route("users")
export class UsersController extends Controller { export class UsersController extends Controller {
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
@Security(KC_SECURITY_NAME, [Claim.UserAdmin]) @Security(KC_SECURITY_NAME, [Claim.UserAdmin])
@SuccessResponse("200", "OK")
@Get() @Get()
public async getUsers(): Promise<User[]> { public async getUsers(): Promise<User[]> {
return await this.userService.getUsers(); return await this.userService.getUsers();
} }
@Security(KC_SECURITY_NAME, [Claim.UserAdmin]) @Security(KC_SECURITY_NAME, [Claim.UserAdmin])
@SuccessResponse("200", "OK")
@Put("/claims/{uid}") @Put("/claims/{uid}")
public async setClaims( public async setClaims(
@Path() uid: string, @Path() uid: string,
@@ -36,12 +37,12 @@ export class UsersController extends Controller {
await this.userService.setClaim(uid, claim, value); await this.userService.setClaim(uid, claim, value);
} }
@Security(KC_SECURITY_NAME) // @Security(KC_SECURITY_NAME)
@Put("/subscriptions/{token}") // @Put("/subscriptions/{token}")
public async setSubscriptions( // public async setSubscriptions(
@Body() topics: Topic[], // @Body() topics: Topic[],
@Path() token: string, // @Path() token: string,
) { // ) {
await this.userService.setSubscriptions(token, topics); // await this.userService.setSubscriptions(token, topics);
} // }
} }
+51 -4
View File
@@ -1,19 +1,66 @@
import express from "express"; import cors from "cors";
import express, {
type NextFunction,
type Request,
type Response,
} from "express";
import { RegisterRoutes } from "./generated/routes.js"; import { RegisterRoutes } from "./generated/routes.js";
import { inject } from "./infrastructure/di/injector.js";
import { sessionHandler } from "./infrastructure/sessionHandler.js"; import { sessionHandler } from "./infrastructure/sessionHandler.js";
import { EnvironmentService } from "./services/environmentService.js";
import { keycloak } from "./services/keycloak/user/index.js"; import { keycloak } from "./services/keycloak/user/index.js";
const app = express(); const app = express();
const port = 3000;
app.use(sessionHandler); app.use(sessionHandler);
app.use(keycloak.middleware()); app.use(keycloak.middleware());
// console.log("not using", keycloak.middleware);
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
app.use(express.json()); app.use(express.json());
app.use(
cors({
origin: [
"https://taekwondo-chemnitz.toni714.de",
"https://tcc-1-ev-intern.web.app",
"http://localhost:4200",
"https://localhost:4200",
],
//TODO reevaluate
}),
);
// const dbService = inject(DatabaseService);
// app.use(async (req, res, next) => {
// await dbService.createTransaction(async prisma => {
// (req as any).transaction = prisma;
// return new Promise<void>((resolve, reject) => {
// res.on("finish", resolve);
// res.on("close", resolve);
// res.on("error", reject);
// next();
// });
// });
// });
RegisterRoutes(app); RegisterRoutes(app);
app.listen(port, () => { const environment = inject(EnvironmentService);
console.log(`Server is running at http://localhost:${port}`); // wants to be last
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
res.status(500);
res.send(JSON.stringify(err));
throw err;
}); });
if (environment.isDev()) {
//TODO create self-signed for localhost
const port = 3000;
app.listen(port, () => {
console.log(`Server is running at http://localhost:${port}`);
});
} else {
//TODO start with HTTPS in production
}
console.log("done");
@@ -1,7 +1,7 @@
import { currentInjector } from "./injector.js";
import { type Token } from "./types.js"; import { type Token } from "./types.js";
const INJECTABLE_META = Symbol("injectable"); const INJECTABLE_META = Symbol("injectable");
const INJECTABLE_REGISTRY = new Set<Token>();
export function Injectable(): ClassDecorator { export function Injectable(): ClassDecorator {
return function (target: any) { return function (target: any) {
@@ -10,14 +10,10 @@ export function Injectable(): ClassDecorator {
writable: false, writable: false,
}); });
INJECTABLE_REGISTRY.add(target); currentInjector.add(target as Token);
}; };
} }
export function isInjectable(targert: any) { export function isInjectable(targert: any) {
return !!targert[INJECTABLE_META]; return !!targert[INJECTABLE_META];
} }
export function getAllInjectables(): Token[] {
return Array.from(INJECTABLE_REGISTRY);
}
+11 -30
View File
@@ -1,21 +1,11 @@
import { getAllInjectables, isInjectable } from "./injectable-decorator.js"; import { type Token } from "./types.js";
import { type Provider, type Token } from "./types.js";
const currentInjector: Injector = createGlobalInjector();
export function inject<T>(token: Token<T>): T {
if (!currentInjector) {
throw new Error("inject() called outside of injection context");
}
return currentInjector.get(token);
}
export class Injector { export class Injector {
private instances = new Map<Token, any>(); private instances = new Map<Token, any>();
private records = new Map<Token, Provider>(); private records = new Set<Token>();
constructor(providers: Provider[]) { add(token: Token) {
providers.forEach(p => this.records.set(p.provide, p)); this.records.add(token);
} }
get<T>(token: Token<T>): T { get<T>(token: Token<T>): T {
@@ -23,8 +13,7 @@ export class Injector {
return this.instances.get(token); return this.instances.get(token);
} }
const provider = this.records.get(token); if (!this.records.has(token)) {
if (!provider) {
throw new Error("No provider for token"); throw new Error("No provider for token");
} }
@@ -38,19 +27,11 @@ export class Injector {
} }
} }
export function createInjector(injectables: Token[]) { export const currentInjector: Injector = new Injector();
const providers: Provider[] = injectables.map(injectable => {
if (!isInjectable(injectable)) {
throw new Error(`${injectable.name} is not injectable`);
}
return { provide: injectable, useClass: injectable };
});
return new Injector(providers); export function inject<T>(token: Token<T>): T {
} if (!currentInjector) {
throw new Error("inject() called outside of injection context");
export function createGlobalInjector() { }
const injectables = getAllInjectables(); return currentInjector.get(token);
return createInjector(injectables);
} }
@@ -1,4 +1 @@
export type Token<T = any> = new (...args: any[]) => T; export type Token<T = any> = new (...args: any[]) => T;
export interface Provider<T = any> {
provide: Token<T>;
}
@@ -19,7 +19,7 @@ async function doKeycloakAuth(
roles?: string[], roles?: string[],
response?: Response, response?: Response,
): Promise<any> { ): Promise<any> {
if (roles) { if (roles && roles.length > 0) {
return await promisifyMiddleware( return await promisifyMiddleware(
request, request,
keycloak.enforcer(roles?.map(role => `realm:${role}`)), keycloak.enforcer(roles?.map(role => `realm:${role}`)),
@@ -35,7 +35,6 @@ export async function expressAuthentication(
roles?: string[], roles?: string[],
response?: Response, response?: Response,
): Promise<any> { ): Promise<any> {
console.dir({ request, securityName, roles });
switch (securityName) { switch (securityName) {
case KC_SECURITY_NAME: case KC_SECURITY_NAME:
return doKeycloakAuth(request, roles, response); return doKeycloakAuth(request, roles, response);
+1 -1
View File
@@ -1,4 +1,4 @@
import { Claim } from "./claim"; import { Claim } from "./claim.js";
export interface User { export interface User {
readonly id?: string; readonly id?: string;
+17 -9
View File
@@ -1,7 +1,13 @@
import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaPg } from "@prisma/adapter-pg";
import { type Request } from "express";
import { PrismaClient } from "../../generated/prisma/client.js"; import { PrismaClient } from "../../generated/prisma/client.js";
import { Injectable } from "../../infrastructure/di/injectable-decorator.js";
type ExtendedClient = ReturnType<typeof createExtendedClient>; type ExtendedClient = ReturnType<typeof createExtendedClient>;
type TransactionClient = Omit<
ExtendedClient,
"$connect" | "$disconnect" | "$on" | "$transaction" | "$extends"
>;
export type SafeClient = Omit< export type SafeClient = Omit<
ExtendedClient, ExtendedClient,
@@ -33,9 +39,9 @@ function createExtendedClient(client: PrismaClient) {
// }); // });
} }
@Injectable()
export class DatabaseService { export class DatabaseService {
private readonly prismaClient: ExtendedClient; private readonly prismaClient: ExtendedClient;
private readonly safeClient: SafeClient;
public constructor() { public constructor() {
const dbUser = process.env["TKD_DB_USER"]; const dbUser = process.env["TKD_DB_USER"];
@@ -46,17 +52,19 @@ export class DatabaseService {
const adapter = new PrismaPg({ connectionString }); const adapter = new PrismaPg({ connectionString });
this.prismaClient = createExtendedClient(new PrismaClient({ adapter })); this.prismaClient = createExtendedClient(new PrismaClient({ adapter }));
this.safeClient = this.prismaClient as SafeClient;
// this.prismaClient.$connect();
// this.prismaClient.$disconnect();
// this.prismaClient.$on();
// this.prismaClient.$transaction();
} }
public async doRequest<T>( public async doRequest<T>(
request: (prisma: SafeClient) => Promise<T>, command: (prisma: SafeClient) => Promise<T>,
request: Request,
): Promise<T> { ): Promise<T> {
return await request(this.safeClient); const transaction = (request as any).transaction as SafeClient;
return await command(transaction);
}
public async createTransaction(
action: (prisma: TransactionClient) => Promise<void>,
) {
await this.prismaClient.$transaction(action);
} }
} }
@@ -0,0 +1,8 @@
import { Injectable } from "../infrastructure/di/index.js";
@Injectable()
export class EnvironmentService {
public isDev(): boolean {
return process.env["NODE_ENV"] !== "production";
}
}
@@ -1,10 +1,10 @@
import RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation"; import { Injectable } from "../../../infrastructure/di/injectable-decorator.js";
import { Injectable } from "../../../infrastructure/di/injectable-decorator";
import kcClientConfig from "./nodejs-api.json" with { type: "json" }; import kcClientConfig from "./nodejs-api.json" with { type: "json" };
import KeycloakAdminClient from "@keycloak/keycloak-admin-client"; import KeycloakAdminClient from "@keycloak/keycloak-admin-client";
import { Claim } from "../../../models/claim"; import type RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation.js";
import { User } from "../../../models/user"; import { Claim } from "../../../models/claim.js";
import { type User } from "../../../models/user.js";
@Injectable() @Injectable()
export class KeycloakAdmin { export class KeycloakAdmin {
@@ -54,7 +54,7 @@ export class KeycloakAdmin {
const client = await this.kcAdminClient; const client = await this.kcAdminClient;
const kcUsers = await client.users.find(); const kcUsers = await client.users.find();
return kcUsers.map(kcUser => ({ return kcUsers.map(kcUser => ({
id: kcUser.id, id: kcUser.id ?? "", //TODO handle undefined id
username: kcUser.username || "", username: kcUser.username || "",
roles: (kcUser.realmRoles || []).map(role => role as Claim), roles: (kcUser.realmRoles || []).map(role => role as Claim),
})); }));
@@ -2,16 +2,16 @@
"clientId": "nodejs-api", "clientId": "nodejs-api",
"name": "NodejsApi", "name": "NodejsApi",
"description": "", "description": "",
"rootUrl": "https://tkd-api.toni714.de", "rootUrl": "http://localhost:3000",
"adminUrl": "https://tkd-api.toni714.de", "adminUrl": "http://localhost:3000",
"baseUrl": "https://tkd-api.toni714.de", "baseUrl": "http://localhost:3000",
"surrogateAuthRequired": false, "surrogateAuthRequired": false,
"enabled": true, "enabled": true,
"alwaysDisplayInConsole": false, "alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret", "clientAuthenticatorType": "client-secret",
"secret": "btdeoaq2mgtoa7SpCEL1phLYcw2gGhGp", "secret": "devsecret",
"redirectUris": ["https://tkd-api.toni714.de/*"], "redirectUris": ["http://localhost:3000/*"],
"webOrigins": ["https://tkd-api.toni714.de"], "webOrigins": ["http://localhost:3000"],
"notBefore": 0, "notBefore": 0,
"bearerOnly": false, "bearerOnly": false,
"consentRequired": false, "consentRequired": false,
@@ -1,10 +1,10 @@
{ {
"realm": "taekwondo", "realm": "taekwondo",
"auth-server-url": "https://keycloak.toni714.de:8443", "auth-server-url": "https://localhost:8080",
"ssl-required": "external", "ssl-required": "external",
"resource": "nodejs-api", "resource": "nodejs-api",
"credentials": { "credentials": {
"secret": "btdeoaq2mgtoa7SpCEL1phLYcw2gGhGp" "secret": "devsecret"
}, },
"confidential-port": 0 "confidential-port": 0
} }
+43 -21
View File
@@ -1,3 +1,4 @@
import express from "express";
import { inject, Injectable } from "../infrastructure/di/index.js"; import { inject, Injectable } from "../infrastructure/di/index.js";
import { ErrorStates } from "../infrastructure/errorStates.js"; import { ErrorStates } from "../infrastructure/errorStates.js";
import { import {
@@ -11,16 +12,22 @@ import { DatabaseService } from "./db/prisma.js";
export class MembersService { export class MembersService {
private readonly database = inject(DatabaseService); private readonly database = inject(DatabaseService);
public async getMembers(): Promise<Member[]> { public async getMembers(request: express.Request): Promise<Member[]> {
const dbMembers = await this.database.doRequest(prisma => const dbMembers = await this.database.doRequest(
prisma.member.findMany(), async prisma => await prisma.member.findMany(),
request,
); );
return dbMembers.map(mapDbMemberToMember); return dbMembers.map(mapDbMemberToMember);
} }
public async getMemberById(memberId: string): Promise<Member> { public async getMemberById(
const member = await this.database.doRequest(prisma => memberId: string,
prisma.member.findFirst({ where: { id: memberId } }), request: express.Request,
): Promise<Member> {
const member = await this.database.doRequest(
async prisma =>
await prisma.member.findFirst({ where: { id: memberId } }),
request,
); );
if (!member) { if (!member) {
@@ -32,36 +39,51 @@ export class MembersService {
return mapDbMemberToMember(member); return mapDbMemberToMember(member);
} }
public async createMember(createArgs: MemberCreateArgs): Promise<Member> { public async createMember(
const newMember = await this.database.doRequest(prisma => createArgs: MemberCreateArgs,
prisma.member.create({ data: createArgs }), request: express.Request,
): Promise<Member> {
const newMember = await this.database.doRequest(
async prisma => await prisma.member.create({ data: createArgs }),
request,
); );
return mapDbMemberToMember(newMember); return mapDbMemberToMember(newMember);
} }
public async updateMember(member: Member): Promise<Member> { public async updateMember(
member: Member,
request: express.Request,
): Promise<Member> {
const id = member.id; const id = member.id;
if (!id) { if (!id) {
throw new Error("Member id is required for update", { throw new Error("Member id is required for update", {
cause: ErrorStates.BAD_REQUEST, cause: ErrorStates.BAD_REQUEST,
}); });
} }
const modifiedMember = await this.database.doRequest(prisma => const modifiedMember = await this.database.doRequest(
prisma.member.update({ async prisma =>
data: { ...member, id: id }, await prisma.member.update({
where: { id: id }, data: { ...member, id: id },
}), where: { id: id },
}),
request,
); );
return mapDbMemberToMember(modifiedMember); return mapDbMemberToMember(modifiedMember);
} }
public async scheduleDeletion(memberId: string, cancelDate: Date) { public async scheduleDeletion(
await this.database.doRequest(prisma => memberId: string,
prisma.member.update({ cancelDate: Date,
where: { id: memberId }, request: express.Request,
data: { kuendigungzum: cancelDate }, ) {
}), await this.database.doRequest(
prisma =>
prisma.member.update({
where: { id: memberId },
data: { kuendigungzum: cancelDate },
}),
request,
); );
} }
} }
+5 -6
View File
@@ -1,7 +1,6 @@
import { inject } from "../infrastructure/di/index.js"; import { inject } from "../infrastructure/di/index.js";
import { Claim } from "../models/claim"; import { Claim } from "../models/claim.js";
import { Topic } from "../models/topic.js"; import { type User } from "../models/user.js";
import { User } from "../models/user.js";
import { KeycloakAdmin } from "./keycloak/admin/index.js"; import { KeycloakAdmin } from "./keycloak/admin/index.js";
export class UserService { export class UserService {
@@ -22,7 +21,7 @@ export class UserService {
return await this.keycloakAdmin.getUsers(); return await this.keycloakAdmin.getUsers();
} }
public async setSubscriptions(token: string, topics: Topic[]) { // public async setSubscriptions(token: string, topics: Topic[]) {
throw new Error("Method not implemented."); // throw new Error("Method not implemented.");
} // }
} }
+1
View File
@@ -39,6 +39,7 @@
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^21.0.2",
"@angular/build": "^20.3.8", "@angular/build": "^20.3.8",
"@angular/cli": "^20.3.8", "@angular/cli": "^20.3.8",
"@angular/compiler-cli": "^20.3.0", "@angular/compiler-cli": "^20.3.0",
+3 -2
View File
@@ -1,5 +1,6 @@
import { import {
ApplicationConfig, ApplicationConfig,
isDevMode,
provideBrowserGlobalErrorListeners, provideBrowserGlobalErrorListeners,
provideZoneChangeDetection, provideZoneChangeDetection,
} from '@angular/core'; } from '@angular/core';
@@ -29,7 +30,7 @@ export const appConfig: ApplicationConfig = {
provideZoneChangeDetection({ eventCoalescing: true }), provideZoneChangeDetection({ eventCoalescing: true }),
provideKeycloak({ provideKeycloak({
config: { config: {
url: 'https://keycloak.toni714.de:8443/', url: isDevMode() ? 'http://localhost:8080/' : 'https://keycloak.toni714.de:8443/',
realm: 'taekwondo', realm: 'taekwondo',
clientId: 'angular-frontend', clientId: 'angular-frontend',
}, },
@@ -44,7 +45,7 @@ export const appConfig: ApplicationConfig = {
provide: INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG, provide: INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG,
useValue: [ useValue: [
createInterceptorCondition<IncludeBearerTokenCondition>({ createInterceptorCondition<IncludeBearerTokenCondition>({
urlPattern: /^https:\/\/tkd-api\.toni714\.de.*$/, urlPattern: isDevMode()? /^http:\/\/localhost:3000.*$/ : /^https:\/\/tkd-api\.toni714\.de.*$/,
}), }),
], ],
}, },
+3 -3
View File
@@ -1,7 +1,7 @@
@if(this.loggedIn()){ @if(this.auth.loggedIn()){
<div class="no-print"> <div class="no-print">
<button mat-flat-button color="accent" (click)="onLogOut()">LogOut</button> <button mat-flat-button color="accent" (click)="this.auth.logout()">LogOut</button>
<a mat-stroked-button color="primary" [routerLink]="['/home']">Home</a> <button mat-stroked-button color="primary" [routerLink]="['/home']">Home</button>
</div> </div>
} }
<router-outlet></router-outlet> <router-outlet></router-outlet>
+7 -2
View File
@@ -16,7 +16,7 @@ export const roles = {
MemberAdmin: 'memberadmin', MemberAdmin: 'memberadmin',
}; };
function requireRoles(roles: string[], any: boolean = false): CanActivateFn { function requireRoles(roles: string[]=[], any: boolean = false): CanActivateFn {
const isAllowed = async ( const isAllowed = async (
_: ActivatedRouteSnapshot, _: ActivatedRouteSnapshot,
__: RouterStateSnapshot, __: RouterStateSnapshot,
@@ -44,7 +44,12 @@ export const routes: Routes = [
{ {
path: '', path: '',
loadComponent: () => import('./components/home/home').then((mod) => mod.Home), loadComponent: () => import('./components/home/home').then((mod) => mod.Home),
canActivate: [requireRoles([roles.MemberAdmin, roles.UserAdmin], true)], canActivate: [requireRoles()],
},
{
path: 'home',
loadComponent: () => import('./components/home/home').then((mod) => mod.Home),
canActivate: [requireRoles()],
}, },
{ path: '**', component: MissingPage }, { path: '**', component: MissingPage },
]; ];
+3 -6
View File
@@ -1,6 +1,7 @@
import { Component, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { RouterModule, RouterOutlet } from '@angular/router'; import { RouterModule, RouterOutlet } from '@angular/router';
import { Authentication } from './services/authentication';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -9,10 +10,6 @@ import { RouterModule, RouterOutlet } from '@angular/router';
styleUrl: './app.scss', styleUrl: './app.scss',
}) })
export class App { export class App {
protected loggedIn = signal(false);
protected readonly title = signal('frontend'); protected readonly title = signal('frontend');
protected readonly auth = inject(Authentication);
protected onLogOut() {
this.loggedIn.set(false);
}
} }
@@ -1 +1,14 @@
<p>home works!</p> <h1>Startseite</h1>
<ul>
<!--TODO fix theming/colors-->
<button matButton="outlined" class="warn-button" [routerLink]="['/test']">
Entwichlungsinformationen
</button>
<button matButton="filled" color="primary" [routerLink]="['/list']">Mitglieder</button>
<button matButton="filled" color="warn" [routerLink]="['/manageUsers']">
Benutzer Verwalten
</button>
<button matButton="filled" color="primary" [routerLink]="['/exam']">
Prüfungsanmeldung
</button>
</ul>
@@ -1,8 +1,10 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
imports: [], imports: [RouterModule, MatButtonModule],
templateUrl: './home.html', templateUrl: './home.html',
styleUrl: './home.scss', styleUrl: './home.scss',
}) })
@@ -1,4 +1,5 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { Authentication } from '../../services/authentication'; import { Authentication } from '../../services/authentication';
@Component({ @Component({
@@ -9,7 +10,13 @@ import { Authentication } from '../../services/authentication';
}) })
export class Login { export class Login {
private readonly authentication = inject(Authentication); private readonly authentication = inject(Authentication);
private readonly router = inject(Router);
public constructor() { public constructor() {
this.authentication.login(); if(this.authentication.loggedIn()){
this.router.navigate(['/home']);
}else{
this.authentication.login();
}
} }
} }
@@ -1,4 +1,4 @@
import { effect, inject, Injectable, signal } from '@angular/core'; import { computed, effect, inject, Injectable, signal } from '@angular/core';
import { import {
KEYCLOAK_EVENT_SIGNAL, KEYCLOAK_EVENT_SIGNAL,
KeycloakEventType, KeycloakEventType,
@@ -22,6 +22,9 @@ export class Authentication {
private readonly _authenticationState = signal<AuthenticationState>(AuthenticationState.Unknown); private readonly _authenticationState = signal<AuthenticationState>(AuthenticationState.Unknown);
private readonly _userInfo = signal<KeycloakProfile | null>(null); private readonly _userInfo = signal<KeycloakProfile | null>(null);
public readonly authenticationState = this._authenticationState.asReadonly(); public readonly authenticationState = this._authenticationState.asReadonly();
public readonly loggedIn = computed(()=>{
return this.authenticationState() === AuthenticationState.Authenticated;
});
public readonly userInfo = this._userInfo.asReadonly(); public readonly userInfo = this._userInfo.asReadonly();
public constructor() { public constructor() {
@@ -69,6 +72,10 @@ export class Authentication {
} }
public async login(location?: string): Promise<void> { public async login(location?: string): Promise<void> {
if(this._authenticationState() === AuthenticationState.Authenticated){
return;
}
var redirectUri = location var redirectUri = location
? `${window.location.origin}/${location}` ? `${window.location.origin}/${location}`
: `${window.location.origin}/login`; : `${window.location.origin}/login`;
+205
View File
@@ -0,0 +1,205 @@
// This file was generated by running 'ng generate @angular/material:theme-color'.
// Proceed with caution if making changes to this file.
@use 'sass:map';
@use '@angular/material' as mat;
// Note: Color palettes are generated from primary: #0047a0, secondary: #cc9c00, tertiary: #cd2e3a, error: #E63241
$_palettes: (
primary: (
0: #000000,
10: #001a43,
20: #002d6c,
25: #003881,
30: #004397,
35: #134fa8,
40: #275bb5,
50: #4575cf,
60: #618feb,
70: #83aaff,
80: #afc6ff,
90: #d8e2ff,
95: #edf0ff,
98: #faf9ff,
99: #fefbff,
100: #ffffff,
),
secondary: (
0: #000000,
10: #251a00,
20: #3f2e00,
25: #4c3800,
30: #5a4300,
35: #694f00,
40: #775a00,
50: #967200,
60: #b58a00,
70: #d5a411,
80: #f3bf33,
90: #ffdf99,
95: #ffefd2,
98: #fff8f2,
99: #fffbff,
100: #ffffff,
),
tertiary: (
0: #000000,
10: #410007,
20: #680010,
25: #7d0016,
30: #92001b,
35: #a60a23,
40: #b81d2d,
50: #db3943,
60: #ff535a,
70: #ff8887,
80: #ffb3b0,
90: #ffdad8,
95: #ffedeb,
98: #fff8f7,
99: #fffbff,
100: #ffffff,
),
neutral: (
0: #000000,
10: #191b21,
20: #2e3036,
25: #393b42,
30: #45464d,
35: #515259,
40: #5d5e65,
50: #75777e,
60: #8f9098,
70: #aaabb2,
80: #c5c6ce,
90: #e2e2ea,
95: #f0f0f8,
98: #faf9ff,
99: #fefbff,
100: #ffffff,
4: #0c0e14,
6: #111319,
12: #1d1f25,
17: #282a30,
22: #33353b,
24: #37393f,
87: #d9d9e1,
92: #e7e7f0,
94: #ededf5,
96: #f3f3fb,
),
neutral-variant: (
0: #000000,
10: #171b25,
20: #2c303b,
25: #373b46,
30: #434752,
35: #4e525e,
40: #5a5e6a,
50: #737783,
60: #8d909d,
70: #a7abb8,
80: #c3c6d4,
90: #dfe2f0,
95: #edf0ff,
98: #faf9ff,
99: #fefbff,
100: #ffffff,
),
error: (
0: #000000,
10: #410007,
20: #680011,
25: #7d0016,
30: #92001c,
35: #a90021,
40: #bd0b29,
50: #e12e3e,
60: #ff535a,
70: #ff8887,
80: #ffb3b1,
90: #ffdad8,
95: #ffedeb,
98: #fff8f7,
99: #fffbff,
100: #ffffff,
),
);
$_rest: (
secondary: map.get($_palettes, secondary),
neutral: map.get($_palettes, neutral),
neutral-variant: map.get($_palettes, neutral-variant),
error: map.get($_palettes, error),
);
$primary-palette: map.merge(map.get($_palettes, primary), $_rest);
$tertiary-palette: map.merge(map.get($_palettes, tertiary), $_rest);
@function _high-contrast-value($light, $dark, $theme-type) {
@if ($theme-type == light) {
@return $light;
}
@if ($theme-type == dark) {
@return $dark;
}
@if ($theme-type == color-scheme) {
@return light-dark(#{$light}, #{$dark});
}
@error 'Unknown theme-type #{$theme-type}. Expected light, dark, or color-scheme';
}
@mixin high-contrast-overrides($theme-type) {
@include mat.theme-overrides((
primary: _high-contrast-value(#002963, #ecefff, $theme-type),
on-primary: _high-contrast-value(#ffffff, #000000, $theme-type),
primary-container: _high-contrast-value(#00459c, #a9c2ff, $theme-type),
on-primary-container: _high-contrast-value(#ffffff, #000a23, $theme-type),
inverse-primary: _high-contrast-value(#afc6ff, #00449a, $theme-type),
primary-fixed: _high-contrast-value(#00459c, #d8e2ff, $theme-type),
primary-fixed-dim: _high-contrast-value(#003070, #afc6ff, $theme-type),
on-primary-fixed: _high-contrast-value(#ffffff, #000000, $theme-type),
on-primary-fixed-variant: _high-contrast-value(#ffffff, #00102f, $theme-type),
secondary: _high-contrast-value(#392a00, #ffeecf, $theme-type),
on-secondary: _high-contrast-value(#ffffff, #000000, $theme-type),
secondary-container: _high-contrast-value(#5d4600, #efbb2f, $theme-type),
on-secondary-container: _high-contrast-value(#ffffff, #110a00, $theme-type),
secondary-fixed: _high-contrast-value(#5d4600, #ffdf99, $theme-type),
secondary-fixed-dim: _high-contrast-value(#413000, #f3bf33, $theme-type),
on-secondary-fixed: _high-contrast-value(#ffffff, #000000, $theme-type),
on-secondary-fixed-variant: _high-contrast-value(#ffffff, #181000, $theme-type),
tertiary: _high-contrast-value(#60000e, #ffecea, $theme-type),
on-tertiary: _high-contrast-value(#ffffff, #000000, $theme-type),
tertiary-container: _high-contrast-value(#97001c, #ffadab, $theme-type),
on-tertiary-container: _high-contrast-value(#ffffff, #220002, $theme-type),
tertiary-fixed: _high-contrast-value(#97001c, #ffdad8, $theme-type),
tertiary-fixed-dim: _high-contrast-value(#6c0011, #ffb3b0, $theme-type),
on-tertiary-fixed: _high-contrast-value(#ffffff, #000000, $theme-type),
on-tertiary-fixed-variant: _high-contrast-value(#ffffff, #2d0003, $theme-type),
background: _high-contrast-value(#faf9ff, #111319, $theme-type),
on-background: _high-contrast-value(#191b21, #e2e2ea, $theme-type),
surface: _high-contrast-value(#faf9ff, #111319, $theme-type),
surface-dim: _high-contrast-value(#b8b8c0, #111319, $theme-type),
surface-bright: _high-contrast-value(#faf9ff, #4e5056, $theme-type),
surface-container-low: _high-contrast-value(#f0f0f8, #1d1f25, $theme-type),
surface-container-lowest: _high-contrast-value(#ffffff, #000000, $theme-type),
surface-container: _high-contrast-value(#e2e2ea, #2e3036, $theme-type),
surface-container-high: _high-contrast-value(#d3d4dc, #393b42, $theme-type),
surface-container-highest: _high-contrast-value(#c5c6ce, #45464d, $theme-type),
on-surface: _high-contrast-value(#000000, #ffffff, $theme-type),
shadow: _high-contrast-value(#000000, #000000, $theme-type),
scrim: _high-contrast-value(#000000, #000000, $theme-type),
surface-tint: _high-contrast-value(#275bb5, #afc6ff, $theme-type),
inverse-surface: _high-contrast-value(#2e3036, #e2e2ea, $theme-type),
inverse-on-surface: _high-contrast-value(#ffffff, #000000, $theme-type),
outline: _high-contrast-value(#282c37, #edeffe, $theme-type),
outline-variant: _high-contrast-value(#454954, #bfc2d0, $theme-type),
error: _high-contrast-value(#60000e, #ffecea, $theme-type),
on-error: _high-contrast-value(#ffffff, #000000, $theme-type),
error-container: _high-contrast-value(#97001d, #ffadab, $theme-type),
on-error-container: _high-contrast-value(#ffffff, #220002, $theme-type),
surface-variant: _high-contrast-value(#dfe2f0, #434752, $theme-type),
on-surface-variant: _high-contrast-value(#000000, #ffffff, $theme-type),
))
}
+16 -8
View File
@@ -5,16 +5,24 @@
// Learn more about theming and how to use it for your application's // Learn more about theming and how to use it for your application's
// custom components at https://material.angular.dev/guide/theming // custom components at https://material.angular.dev/guide/theming
@use '@angular/material' as mat; @use '@angular/material' as mat;
@use './styles.colors.scss' as my-theme;
@include mat.core();
@include mat.all-component-themes(my-theme.$light-theme);
@include mat.color-variants-backwards-compatibility(my-theme.$light-theme);
html { html {
@include mat.theme(( // @include mat.theme((
color: ( // color: (
primary: mat.$cyan-palette, // primary: my-theme.$primary-palette,
tertiary: mat.$orange-palette, // tertiary: my-theme.$tertiary-palette,
), // ),
typography: Roboto, // typography: Roboto,
density: 0, // density: 0,
)); // ));
@include mat.core-theme(my-theme.$light-theme);
@include mat.button-theme(my-theme.$light-theme);
} }
body { body {
+5332 -564
View File
File diff suppressed because it is too large Load Diff