From dbe4f52871cf7b62e61cc6fb6f43b7ad8e7a06ae Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Fri, 27 Feb 2026 21:46:50 +0100 Subject: [PATCH] featur add fahrmeister --- backend/src/controllers/auth.controller.ts | 6 + backend/src/controllers/vehicle.controller.ts | 45 +++++-- .../007_add_groups_and_vehicle_periods.sql | 110 ++++++++++++++++++ backend/src/middleware/auth.middleware.ts | 3 + backend/src/middleware/rbac.middleware.ts | 41 +++++++ backend/src/models/user.model.ts | 2 + backend/src/models/vehicle.model.ts | 109 ++++++++++------- backend/src/routes/vehicle.routes.ts | 71 ++++------- backend/src/services/token.service.ts | 1 + backend/src/services/user.service.ts | 32 +++-- backend/src/services/vehicle.service.ts | 41 ++++++- backend/src/types/auth.types.ts | 1 + frontend/src/hooks/usePermissions.ts | 12 ++ frontend/src/pages/FahrzeugDetail.tsx | 58 +++++---- frontend/src/pages/Fahrzeuge.tsx | 26 +++-- frontend/src/services/vehicles.ts | 4 + frontend/src/types/vehicle.types.ts | 16 ++- 17 files changed, 426 insertions(+), 152 deletions(-) create mode 100644 backend/src/database/migrations/007_add_groups_and_vehicle_periods.sql create mode 100644 frontend/src/hooks/usePermissions.ts diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index 88f7cda..1c01666 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -35,6 +35,7 @@ class AuthController { // Step 2: Get user info from Authentik const userInfo = await authentikService.getUserInfo(tokens.access_token); + const groups = userInfo.groups ?? []; // Step 3: Verify ID token if present if (tokens.id_token) { @@ -65,6 +66,8 @@ class AuthController { profile_picture_url: userInfo.picture, }); + await userService.updateGroups(user.id, groups); + // Audit: first-ever login (user record creation) auditService.logAudit({ user_id: user.id, @@ -86,6 +89,7 @@ class AuthController { }); await userService.updateLastLogin(user.id); + await userService.updateGroups(user.id, groups); // Audit: returning user login auditService.logAudit({ @@ -132,6 +136,7 @@ class AuthController { userId: user.id, email: user.email, authentikSub: user.authentik_sub, + groups, }); // Generate refresh token @@ -161,6 +166,7 @@ class AuthController { familyName: user.family_name, profilePictureUrl: user.profile_picture_url, isActive: user.is_active, + groups, }, }, }); diff --git a/backend/src/controllers/vehicle.controller.ts b/backend/src/controllers/vehicle.controller.ts index ac117fa..a60eefc 100644 --- a/backend/src/controllers/vehicle.controller.ts +++ b/backend/src/controllers/vehicle.controller.ts @@ -29,18 +29,20 @@ const isoDate = z.string().regex( ); const CreateFahrzeugSchema = z.object({ - bezeichnung: z.string().min(1).max(100), - kurzname: z.string().max(20).optional(), - amtliches_kennzeichen: z.string().max(20).optional(), - fahrgestellnummer: z.string().max(50).optional(), - baujahr: z.number().int().min(1950).max(2100).optional(), - hersteller: z.string().max(100).optional(), - typ_schluessel: z.string().max(30).optional(), - besatzung_soll: z.string().max(10).optional(), - status: FahrzeugStatusEnum.optional(), - status_bemerkung: z.string().max(500).optional(), - standort: z.string().max(100).optional(), - bild_url: z.string().url().max(500).optional(), + bezeichnung: z.string().min(1).max(100), + kurzname: z.string().max(20).optional(), + amtliches_kennzeichen: z.string().max(20).optional(), + fahrgestellnummer: z.string().max(50).optional(), + baujahr: z.number().int().min(1950).max(2100).optional(), + hersteller: z.string().max(100).optional(), + typ_schluessel: z.string().max(30).optional(), + besatzung_soll: z.string().max(10).optional(), + status: FahrzeugStatusEnum.optional(), + status_bemerkung: z.string().max(500).optional(), + standort: z.string().max(100).optional(), + bild_url: z.string().url().max(500).optional(), + paragraph57a_faellig_am: isoDate.optional(), + naechste_wartung_am: isoDate.optional(), }); const UpdateFahrzeugSchema = CreateFahrzeugSchema.partial(); @@ -325,6 +327,25 @@ class VehicleController { } } + /** + * DELETE /api/vehicles/:id + * Delete a vehicle. Requires dashboard_admin group. + */ + async deleteVehicle(req: Request, res: Response): Promise { + try { + const { id } = req.params as Record; + await vehicleService.deleteVehicle(id, getUserId(req)); + res.status(200).json({ success: true, message: 'Fahrzeug gelöscht' }); + } catch (error: any) { + if (error?.message === 'Vehicle not found') { + res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' }); + return; + } + logger.error('deleteVehicle error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht gelöscht werden' }); + } + } + /** * GET /api/vehicles/:id/wartung * Maintenance log for a vehicle. diff --git a/backend/src/database/migrations/007_add_groups_and_vehicle_periods.sql b/backend/src/database/migrations/007_add_groups_and_vehicle_periods.sql new file mode 100644 index 0000000..6e3f12d --- /dev/null +++ b/backend/src/database/migrations/007_add_groups_and_vehicle_periods.sql @@ -0,0 +1,110 @@ +-- Migration 007: Authentik groups + vehicle inspection/service periods +-- Depends on: 001_create_users_table.sql, 005_create_fahrzeuge.sql +-- +-- Changes: +-- 1. Add authentik_groups column to users (stores Authentik group memberships) +-- 2. Add paragraph57a_faellig_am + naechste_wartung_am to fahrzeuge +-- 3. Refresh the fahrzeuge_mit_pruefstatus view to expose the new columns +-- Rollback: +-- ALTER TABLE users DROP COLUMN IF EXISTS authentik_groups; +-- ALTER TABLE fahrzeuge DROP COLUMN IF EXISTS paragraph57a_faellig_am; +-- ALTER TABLE fahrzeuge DROP COLUMN IF EXISTS naechste_wartung_am; + +-- ── 1. users: Authentik group memberships ───────────────────────────────────── +ALTER TABLE users + ADD COLUMN IF NOT EXISTS authentik_groups TEXT[] NOT NULL DEFAULT '{}'; + +COMMENT ON COLUMN users.authentik_groups IS + 'Authentik group slugs synced on every login (e.g. dashboard_admin, fahrmeister)'; + +-- ── 2. fahrzeuge: §57a (Austrian periodic inspection) + service interval ────── +ALTER TABLE fahrzeuge + ADD COLUMN IF NOT EXISTS paragraph57a_faellig_am DATE; + +COMMENT ON COLUMN fahrzeuge.paragraph57a_faellig_am IS + '§57a StVO periodic inspection due date (Austrian equivalent of HU/TÜV)'; + +ALTER TABLE fahrzeuge + ADD COLUMN IF NOT EXISTS naechste_wartung_am DATE; + +COMMENT ON COLUMN fahrzeuge.naechste_wartung_am IS + 'Next scheduled service / maintenance due date'; + +-- ── 3. Refresh view to expose new vehicle columns ───────────────────────────── +-- Drop and recreate since CREATE OR REPLACE on views requires identical column list. +DROP VIEW IF EXISTS fahrzeuge_mit_pruefstatus; + +CREATE OR REPLACE VIEW fahrzeuge_mit_pruefstatus AS +WITH latest_pruefungen AS ( + SELECT DISTINCT ON (fahrzeug_id, pruefung_art) + fahrzeug_id, + pruefung_art, + id AS pruefung_id, + faellig_am, + durchgefuehrt_am, + ergebnis, + naechste_faelligkeit, + pruefende_stelle, + CURRENT_DATE - faellig_am::date AS tage_ueberfaellig, + faellig_am::date - CURRENT_DATE AS tage_bis_faelligkeit + FROM fahrzeug_pruefungen + ORDER BY + fahrzeug_id, + pruefung_art, + (durchgefuehrt_am IS NULL) DESC, + faellig_am DESC +) +SELECT + f.id, + f.bezeichnung, + f.kurzname, + f.amtliches_kennzeichen, + f.fahrgestellnummer, + f.baujahr, + f.hersteller, + f.typ_schluessel, + f.besatzung_soll, + f.status, + f.status_bemerkung, + f.standort, + f.bild_url, + f.created_at, + f.updated_at, + -- §57a Austrian periodic inspection + f.paragraph57a_faellig_am, + f.paragraph57a_faellig_am::date - CURRENT_DATE AS paragraph57a_tage_bis_faelligkeit, + -- Next service/maintenance + f.naechste_wartung_am, + f.naechste_wartung_am::date - CURRENT_DATE AS wartung_tage_bis_faelligkeit, + -- Legacy pruefungen (HU / AU / UVV / Leiter) kept for backwards compat + hu.pruefung_id AS hu_pruefung_id, + hu.faellig_am AS hu_faellig_am, + hu.tage_bis_faelligkeit AS hu_tage_bis_faelligkeit, + hu.ergebnis AS hu_ergebnis, + au.pruefung_id AS au_pruefung_id, + au.faellig_am AS au_faellig_am, + au.tage_bis_faelligkeit AS au_tage_bis_faelligkeit, + au.ergebnis AS au_ergebnis, + uvv.pruefung_id AS uvv_pruefung_id, + uvv.faellig_am AS uvv_faellig_am, + uvv.tage_bis_faelligkeit AS uvv_tage_bis_faelligkeit, + uvv.ergebnis AS uvv_ergebnis, + leiter.pruefung_id AS leiter_pruefung_id, + leiter.faellig_am AS leiter_faellig_am, + leiter.tage_bis_faelligkeit AS leiter_tage_bis_faelligkeit, + leiter.ergebnis AS leiter_ergebnis, + -- Overall worst urgency: §57a + Wartung take precedence, legacy pruefungen kept + LEAST( + f.paragraph57a_faellig_am::date - CURRENT_DATE, + f.naechste_wartung_am::date - CURRENT_DATE, + hu.tage_bis_faelligkeit, + au.tage_bis_faelligkeit, + uvv.tage_bis_faelligkeit, + leiter.tage_bis_faelligkeit + ) AS naechste_pruefung_tage +FROM + fahrzeuge f + LEFT JOIN latest_pruefungen hu ON hu.fahrzeug_id = f.id AND hu.pruefung_art = 'HU' + LEFT JOIN latest_pruefungen au ON au.fahrzeug_id = f.id AND au.pruefung_art = 'AU' + LEFT JOIN latest_pruefungen uvv ON uvv.fahrzeug_id = f.id AND uvv.pruefung_art = 'UVV' + LEFT JOIN latest_pruefungen leiter ON leiter.fahrzeug_id = f.id AND leiter.pruefung_art = 'Leiter'; diff --git a/backend/src/middleware/auth.middleware.ts b/backend/src/middleware/auth.middleware.ts index bb7d61a..54a7111 100644 --- a/backend/src/middleware/auth.middleware.ts +++ b/backend/src/middleware/auth.middleware.ts @@ -43,6 +43,7 @@ declare global { email: string; authentikSub: string; role?: AppRole; // populated when role is stored in DB / JWT + groups?: string[]; }; } } @@ -120,6 +121,7 @@ export const authenticate = async ( id: decoded.userId, email: decoded.email, authentikSub: decoded.authentikSub, + groups: decoded.groups ?? [], }; logger.debug('User authenticated successfully', { @@ -225,6 +227,7 @@ export const optionalAuth = async ( id: decoded.userId, email: decoded.email, authentikSub: decoded.authentikSub, + groups: decoded.groups ?? [], }; } } catch (error) { diff --git a/backend/src/middleware/rbac.middleware.ts b/backend/src/middleware/rbac.middleware.ts index 211d875..fc632fb 100644 --- a/backend/src/middleware/rbac.middleware.ts +++ b/backend/src/middleware/rbac.middleware.ts @@ -134,3 +134,44 @@ export function requirePermission(permission: string) { } export { getUserRole, hasPermission }; + +/** + * Middleware factory: requires the authenticated user to belong to at least + * one of the given Authentik groups (sourced from the JWT `groups` claim). + * + * Usage: + * router.post('/api/vehicles', authenticate, requireGroups(['dashboard_admin']), handler) + */ +export function requireGroups(requiredGroups: string[]) { + return async (req: Request, res: Response, next: NextFunction): Promise => { + if (!req.user) { + res.status(401).json({ success: false, message: 'Authentication required' }); + return; + } + + const userGroups: string[] = (req.user as any).groups ?? []; + const hasAccess = requiredGroups.some(g => userGroups.includes(g)); + + if (!hasAccess) { + logger.warn('Group-based access denied', { + userId: req.user.id, + userGroups, + requiredGroups, + path: req.path, + }); + + auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, { + required_groups: requiredGroups, + user_groups: userGroups, + }); + + res.status(403).json({ + success: false, + message: 'Keine Berechtigung für diese Aktion', + }); + return; + } + + next(); + }; +} diff --git a/backend/src/models/user.model.ts b/backend/src/models/user.model.ts index ec4bf75..d654c24 100644 --- a/backend/src/models/user.model.ts +++ b/backend/src/models/user.model.ts @@ -14,6 +14,7 @@ export interface User { created_at: Date; updated_at: Date; preferences?: any; // JSONB + authentik_groups: string[]; } export interface CreateUserData { @@ -24,6 +25,7 @@ export interface CreateUserData { given_name?: string; family_name?: string; profile_picture_url?: string; + authentik_groups?: string[]; } export interface UpdateUserData { diff --git a/backend/src/models/vehicle.model.ts b/backend/src/models/vehicle.model.ts index 9fb3dcb..9c64e2e 100644 --- a/backend/src/models/vehicle.model.ts +++ b/backend/src/models/vehicle.model.ts @@ -83,21 +83,25 @@ export type WartungslogArt = /** Raw database row from the `fahrzeuge` table */ export interface Fahrzeug { - id: string; // UUID - bezeichnung: string; // e.g. "LF 20/16" - kurzname: string | null; - amtliches_kennzeichen: string | null; - fahrgestellnummer: string | null; - baujahr: number | null; - hersteller: string | null; - typ_schluessel: string | null; - besatzung_soll: string | null; // e.g. "1/8" - status: FahrzeugStatus; - status_bemerkung: string | null; - standort: string; - bild_url: string | null; - created_at: Date; - updated_at: Date; + id: string; // UUID + bezeichnung: string; // e.g. "LF 20/16" + kurzname: string | null; + amtliches_kennzeichen: string | null; + fahrgestellnummer: string | null; + baujahr: number | null; + hersteller: string | null; + typ_schluessel: string | null; + besatzung_soll: string | null; // e.g. "1/8" + status: FahrzeugStatus; + status_bemerkung: string | null; + standort: string; + bild_url: string | null; + /** §57a StVO periodic inspection due date (Austrian equivalent of HU/TÜV) */ + paragraph57a_faellig_am: Date | null; + /** Next scheduled service / maintenance due date */ + naechste_wartung_am: Date | null; + created_at: Date; + updated_at: Date; } /** Raw database row from `fahrzeug_pruefungen` */ @@ -153,6 +157,10 @@ export interface FahrzeugWithPruefstatus extends Fahrzeug { uvv: PruefungStatus; leiter: PruefungStatus; }; + /** Days until §57a inspection (negative = overdue) */ + paragraph57a_tage_bis_faelligkeit: number | null; + /** Days until next service (negative = overdue) */ + wartung_tage_bis_faelligkeit: number | null; /** Minimum tage_bis_faelligkeit across all inspections (negative = any overdue) */ naechste_pruefung_tage: number | null; /** Full inspection history, ordered by faellig_am DESC */ @@ -168,25 +176,32 @@ export interface FahrzeugWithPruefstatus extends Fahrzeug { * Includes only the fields needed to render a card plus inspection badges. */ export interface FahrzeugListItem { - id: string; - bezeichnung: string; - kurzname: string | null; - amtliches_kennzeichen: string | null; - baujahr: number | null; - hersteller: string | null; - besatzung_soll: string | null; - status: FahrzeugStatus; - status_bemerkung: string | null; - bild_url: string | null; - hu_faellig_am: Date | null; - hu_tage_bis_faelligkeit: number | null; - au_faellig_am: Date | null; - au_tage_bis_faelligkeit: number | null; - uvv_faellig_am: Date | null; - uvv_tage_bis_faelligkeit: number | null; - leiter_faellig_am: Date | null; + id: string; + bezeichnung: string; + kurzname: string | null; + amtliches_kennzeichen: string | null; + baujahr: number | null; + hersteller: string | null; + besatzung_soll: string | null; + status: FahrzeugStatus; + status_bemerkung: string | null; + bild_url: string | null; + /** §57a due date (primary inspection badge) */ + paragraph57a_faellig_am: Date | null; + paragraph57a_tage_bis_faelligkeit: number | null; + /** Next service due date */ + naechste_wartung_am: Date | null; + wartung_tage_bis_faelligkeit: number | null; + // Legacy pruefungen kept for backwards compat + hu_faellig_am: Date | null; + hu_tage_bis_faelligkeit: number | null; + au_faellig_am: Date | null; + au_tage_bis_faelligkeit: number | null; + uvv_faellig_am: Date | null; + uvv_tage_bis_faelligkeit: number | null; + leiter_faellig_am: Date | null; leiter_tage_bis_faelligkeit: number | null; - naechste_pruefung_tage: number | null; + naechste_pruefung_tage: number | null; } // ── Dashboard KPI ───────────────────────────────────────────────────────────── @@ -229,21 +244,25 @@ export interface CreateFahrzeugData { status_bemerkung?: string; standort?: string; bild_url?: string; + paragraph57a_faellig_am?: string; // ISO date 'YYYY-MM-DD' + naechste_wartung_am?: string; // ISO date 'YYYY-MM-DD' } export interface UpdateFahrzeugData { - bezeichnung?: string; - kurzname?: string | null; - amtliches_kennzeichen?: string | null; - fahrgestellnummer?: string | null; - baujahr?: number | null; - hersteller?: string | null; - typ_schluessel?: string | null; - besatzung_soll?: string | null; - status?: FahrzeugStatus; - status_bemerkung?: string | null; - standort?: string; - bild_url?: string | null; + bezeichnung?: string; + kurzname?: string | null; + amtliches_kennzeichen?: string | null; + fahrgestellnummer?: string | null; + baujahr?: number | null; + hersteller?: string | null; + typ_schluessel?: string | null; + besatzung_soll?: string | null; + status?: FahrzeugStatus; + status_bemerkung?: string | null; + standort?: string; + bild_url?: string | null; + paragraph57a_faellig_am?: string | null; // ISO date 'YYYY-MM-DD' + naechste_wartung_am?: string | null; // ISO date 'YYYY-MM-DD' } export interface CreatePruefungData { diff --git a/backend/src/routes/vehicle.routes.ts b/backend/src/routes/vehicle.routes.ts index b2b67e7..d576642 100644 --- a/backend/src/routes/vehicle.routes.ts +++ b/backend/src/routes/vehicle.routes.ts @@ -1,49 +1,10 @@ import { Router } from 'express'; import vehicleController from '../controllers/vehicle.controller'; import { authenticate } from '../middleware/auth.middleware'; +import { requireGroups } from '../middleware/rbac.middleware'; -// --------------------------------------------------------------------------- -// RBAC guard — requirePermission('vehicles:write') -// --------------------------------------------------------------------------- -// Tier 1 will deliver a full RBAC middleware. Until then, this inline guard -// enforces that only admin/kommandant/gruppenfuehrer roles can mutate vehicle -// data. The role is expected on req.user once Tier 1 is complete. -// For now it uses a conservative allowlist that can be updated via Tier 1 RBAC. -// --------------------------------------------------------------------------- -import { Request, Response, NextFunction } from 'express'; - -/** Roles that are allowed to write vehicle data */ -const WRITE_ROLES = new Set(['admin', 'kommandant', 'gruppenfuehrer']); - -/** - * requirePermission guard — temporary inline implementation. - * Replace with the Tier 1 RBAC middleware when available: - * import { requirePermission } from '../middleware/rbac.middleware'; - */ -const requireVehicleWrite = ( - req: Request, - res: Response, - next: NextFunction -): void => { - // Once Tier 1 RBAC is merged, replace the body with: - // return requirePermission('vehicles:write')(req, res, next); - // - // Temporary implementation: check the role field on the JWT payload. - // The role is stored in req.user once authenticate() has run (Tier 1 adds it). - const role = (req.user as any)?.role as string | undefined; - - if (!role || !WRITE_ROLES.has(role)) { - res.status(403).json({ - success: false, - message: 'Keine Berechtigung für diese Aktion (vehicles:write erforderlich)', - }); - return; - } - - next(); -}; - -// --------------------------------------------------------------------------- +const ADMIN_GROUPS = ['dashboard_admin']; +const STATUS_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister']; const router = Router(); @@ -86,7 +47,7 @@ router.get('/:id/pruefungen', authenticate, vehicleController.getPruefungen.bind */ router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController)); -// ── Write endpoints (vehicles:write role required) ───────────────────────────── +// ── Write endpoints (dashboard_admin group required) ──────────────────────── /** * POST /api/vehicles @@ -95,7 +56,7 @@ router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehic router.post( '/', authenticate, - requireVehicleWrite, + requireGroups(ADMIN_GROUPS), vehicleController.createVehicle.bind(vehicleController) ); @@ -106,19 +67,19 @@ router.post( router.patch( '/:id', authenticate, - requireVehicleWrite, + requireGroups(ADMIN_GROUPS), vehicleController.updateVehicle.bind(vehicleController) ); /** * PATCH /api/vehicles/:id/status - * Live status change — Socket.IO hook point for Tier 3. + * Live status change — dashboard_admin or dashboard_fahrmeister required. * The `io` instance is retrieved inside the controller via req.app.get('io'). */ router.patch( '/:id/status', authenticate, - requireVehicleWrite, + requireGroups(STATUS_GROUPS), vehicleController.updateVehicleStatus.bind(vehicleController) ); @@ -129,7 +90,7 @@ router.patch( router.post( '/:id/pruefungen', authenticate, - requireVehicleWrite, + requireGroups(ADMIN_GROUPS), vehicleController.addPruefung.bind(vehicleController) ); @@ -140,8 +101,20 @@ router.post( router.post( '/:id/wartung', authenticate, - requireVehicleWrite, + requireGroups(ADMIN_GROUPS), vehicleController.addWartung.bind(vehicleController) ); +/** + * DELETE /api/vehicles/:id + * Delete a vehicle — dashboard_admin only. + * NOTE: vehicleController.deleteVehicle needs to be implemented. + */ +router.delete( + '/:id', + authenticate, + requireGroups(ADMIN_GROUPS), + vehicleController.deleteVehicle.bind(vehicleController) +); + export default router; diff --git a/backend/src/services/token.service.ts b/backend/src/services/token.service.ts index a0cb5e6..89141ab 100644 --- a/backend/src/services/token.service.ts +++ b/backend/src/services/token.service.ts @@ -14,6 +14,7 @@ class TokenService { userId: payload.userId, email: payload.email, authentikSub: payload.authentikSub, + groups: payload.groups ?? [], }, environment.jwt.secret, { diff --git a/backend/src/services/user.service.ts b/backend/src/services/user.service.ts index bb47cf3..38aa86b 100644 --- a/backend/src/services/user.service.ts +++ b/backend/src/services/user.service.ts @@ -11,7 +11,7 @@ class UserService { const query = ` SELECT id, email, authentik_sub, name, preferred_username, given_name, family_name, profile_picture_url, refresh_token, refresh_token_expires_at, - is_active, last_login_at, created_at, updated_at, preferences + is_active, last_login_at, created_at, updated_at, preferences, authentik_groups FROM users WHERE authentik_sub = $1 `; @@ -39,7 +39,7 @@ class UserService { const query = ` SELECT id, email, authentik_sub, name, preferred_username, given_name, family_name, profile_picture_url, refresh_token, refresh_token_expires_at, - is_active, last_login_at, created_at, updated_at, preferences + is_active, last_login_at, created_at, updated_at, preferences, authentik_groups FROM users WHERE email = $1 `; @@ -67,7 +67,7 @@ class UserService { const query = ` SELECT id, email, authentik_sub, name, preferred_username, given_name, family_name, profile_picture_url, refresh_token, refresh_token_expires_at, - is_active, last_login_at, created_at, updated_at, preferences + is_active, last_login_at, created_at, updated_at, preferences, authentik_groups FROM users WHERE id = $1 `; @@ -101,12 +101,13 @@ class UserService { given_name, family_name, profile_picture_url, - is_active + is_active, + authentik_groups ) - VALUES ($1, $2, $3, $4, $5, $6, $7, true) + VALUES ($1, $2, $3, $4, $5, $6, $7, true, $8) RETURNING id, email, authentik_sub, name, preferred_username, given_name, family_name, profile_picture_url, refresh_token, refresh_token_expires_at, - is_active, last_login_at, created_at, updated_at, preferences + is_active, last_login_at, created_at, updated_at, preferences, authentik_groups `; const values = [ @@ -117,6 +118,7 @@ class UserService { userData.given_name || null, userData.family_name || null, userData.profile_picture_url || null, + userData.authentik_groups ?? [], ]; const result = await pool.query(query, values); @@ -185,7 +187,7 @@ class UserService { WHERE id = $${paramCount} RETURNING id, email, authentik_sub, name, preferred_username, given_name, family_name, profile_picture_url, refresh_token, refresh_token_expires_at, - is_active, last_login_at, created_at, updated_at, preferences + is_active, last_login_at, created_at, updated_at, preferences, authentik_groups `; const result = await pool.query(query, values); @@ -270,6 +272,22 @@ class UserService { return false; } } + + /** + * Sync Authentik groups for a user + */ + async updateGroups(id: string, groups: string[]): Promise { + try { + await pool.query( + `UPDATE users SET authentik_groups = $1 WHERE id = $2`, + [groups, id] + ); + logger.debug('Updated authentik_groups', { userId: id }); + } catch (error) { + logger.error('Error updating authentik_groups', { error, userId: id }); + throw new Error('Failed to update user groups'); + } + } } export default new UserService(); diff --git a/backend/src/services/vehicle.service.ts b/backend/src/services/vehicle.service.ts index 37e0e99..79b195a 100644 --- a/backend/src/services/vehicle.service.ts +++ b/backend/src/services/vehicle.service.ts @@ -63,6 +63,10 @@ class VehicleService { status, status_bemerkung, bild_url, + paragraph57a_faellig_am, + paragraph57a_tage_bis_faelligkeit, + naechste_wartung_am, + wartung_tage_bis_faelligkeit, hu_faellig_am, hu_tage_bis_faelligkeit, au_faellig_am, @@ -78,6 +82,10 @@ class VehicleService { return result.rows.map((row) => ({ ...row, + paragraph57a_tage_bis_faelligkeit: row.paragraph57a_tage_bis_faelligkeit != null + ? parseInt(row.paragraph57a_tage_bis_faelligkeit, 10) : null, + wartung_tage_bis_faelligkeit: row.wartung_tage_bis_faelligkeit != null + ? parseInt(row.wartung_tage_bis_faelligkeit, 10) : null, hu_tage_bis_faelligkeit: row.hu_tage_bis_faelligkeit != null ? parseInt(row.hu_tage_bis_faelligkeit, 10) : null, au_tage_bis_faelligkeit: row.au_tage_bis_faelligkeit != null @@ -145,6 +153,8 @@ class VehicleService { status_bemerkung: row.status_bemerkung, standort: row.standort, bild_url: row.bild_url, + paragraph57a_faellig_am: row.paragraph57a_faellig_am ?? null, + naechste_wartung_am: row.naechste_wartung_am ?? null, created_at: row.created_at, updated_at: row.updated_at, pruefstatus: { @@ -153,6 +163,10 @@ class VehicleService { uvv: mapPruefungStatus(row, 'uvv'), leiter: mapPruefungStatus(row, 'leiter'), }, + paragraph57a_tage_bis_faelligkeit: row.paragraph57a_tage_bis_faelligkeit != null + ? parseInt(row.paragraph57a_tage_bis_faelligkeit, 10) : null, + wartung_tage_bis_faelligkeit: row.wartung_tage_bis_faelligkeit != null + ? parseInt(row.wartung_tage_bis_faelligkeit, 10) : null, naechste_pruefung_tage: row.naechste_pruefung_tage != null ? parseInt(row.naechste_pruefung_tage, 10) : null, pruefungen: pruefungenResult.rows as FahrzeugPruefung[], @@ -179,8 +193,9 @@ class VehicleService { `INSERT INTO fahrzeuge ( bezeichnung, kurzname, amtliches_kennzeichen, fahrgestellnummer, baujahr, hersteller, typ_schluessel, besatzung_soll, - status, status_bemerkung, standort, bild_url - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + status, status_bemerkung, standort, bild_url, + paragraph57a_faellig_am, naechste_wartung_am + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`, [ data.bezeichnung, @@ -195,6 +210,8 @@ class VehicleService { data.status_bemerkung ?? null, data.standort ?? 'Feuerwehrhaus', data.bild_url ?? null, + data.paragraph57a_faellig_am ?? null, + data.naechste_wartung_am ?? null, ] ); @@ -234,6 +251,8 @@ class VehicleService { if (data.status_bemerkung !== undefined) addField('status_bemerkung', data.status_bemerkung); if (data.standort !== undefined) addField('standort', data.standort); if (data.bild_url !== undefined) addField('bild_url', data.bild_url); + if (data.paragraph57a_faellig_am !== undefined) addField('paragraph57a_faellig_am', data.paragraph57a_faellig_am); + if (data.naechste_wartung_am !== undefined) addField('naechste_wartung_am', data.naechste_wartung_am); if (fields.length === 0) { throw new Error('No fields to update'); @@ -258,6 +277,24 @@ class VehicleService { } } + async deleteVehicle(id: string, deletedBy: string): Promise { + try { + const result = await pool.query( + `DELETE FROM fahrzeuge WHERE id = $1 RETURNING id`, + [id] + ); + + if (result.rows.length === 0) { + throw new Error('Vehicle not found'); + } + + logger.info('Vehicle deleted', { id, by: deletedBy }); + } catch (error) { + logger.error('VehicleService.deleteVehicle failed', { error, id }); + throw error; + } + } + // ========================================================================= // STATUS MANAGEMENT // Socket.io-ready: accepts optional `io` parameter. diff --git a/backend/src/types/auth.types.ts b/backend/src/types/auth.types.ts index 4809495..51c8f4e 100644 --- a/backend/src/types/auth.types.ts +++ b/backend/src/types/auth.types.ts @@ -27,6 +27,7 @@ export interface JwtPayload { userId: string; // UUID email: string; authentikSub: string; + groups?: string[]; iat?: number; exp?: number; } diff --git a/frontend/src/hooks/usePermissions.ts b/frontend/src/hooks/usePermissions.ts new file mode 100644 index 0000000..eb0bcf2 --- /dev/null +++ b/frontend/src/hooks/usePermissions.ts @@ -0,0 +1,12 @@ +import { useAuth } from '../contexts/AuthContext'; + +export function usePermissions() { + const { user } = useAuth(); + const groups = user?.groups ?? []; + + return { + isAdmin: groups.includes('dashboard_admin'), + canChangeStatus: groups.includes('dashboard_admin') || groups.includes('dashboard_fahrmeister'), + groups, + }; +} diff --git a/frontend/src/pages/FahrzeugDetail.tsx b/frontend/src/pages/FahrzeugDetail.tsx index 9a83b6e..cd6c860 100644 --- a/frontend/src/pages/FahrzeugDetail.tsx +++ b/frontend/src/pages/FahrzeugDetail.tsx @@ -62,6 +62,7 @@ import { WartungslogArt, PruefungErgebnis, } from '../types/vehicle.types'; +import { usePermissions } from '../hooks/usePermissions'; // ── Tab Panel ───────────────────────────────────────────────────────────────── @@ -114,9 +115,10 @@ function inspectionBadgeColor(tage: number | null): 'success' | 'warning' | 'err interface UebersichtTabProps { vehicle: FahrzeugDetail; onStatusUpdated: () => void; + canChangeStatus: boolean; } -const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated }) => { +const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, canChangeStatus }) => { const [statusDialogOpen, setStatusDialogOpen] = useState(false); const [newStatus, setNewStatus] = useState(vehicle.status); const [bemerkung, setBemerkung] = useState(vehicle.status_bemerkung ?? ''); @@ -177,6 +179,7 @@ const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated setBemerkung(vehicle.status_bemerkung ?? ''); setStatusDialogOpen(true); }} + sx={{ display: canChangeStatus ? undefined : 'none' }} > Status ändern @@ -195,6 +198,8 @@ const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated { label: 'Typ (DIN 14502)', value: vehicle.typ_schluessel }, { label: 'Besatzung (Soll)', value: vehicle.besatzung_soll }, { label: 'Standort', value: vehicle.standort }, + { label: '§57a fällig am', value: fmtDate(vehicle.paragraph57a_faellig_am) !== '—' ? fmtDate(vehicle.paragraph57a_faellig_am) : null }, + { label: 'Nächste Wartung', value: fmtDate(vehicle.naechste_wartung_am) !== '—' ? fmtDate(vehicle.naechste_wartung_am) : null }, ].map(({ label, value }) => ( @@ -311,6 +316,7 @@ interface PruefungenTabProps { fahrzeugId: string; pruefungen: FahrzeugPruefung[]; onAdded: () => void; + canWrite: boolean; } const ERGEBNIS_LABELS: Record = { @@ -327,7 +333,7 @@ const ERGEBNIS_COLORS: Record = ({ fahrzeugId, pruefungen, onAdded }) => { +const PruefungenTab: React.FC = ({ fahrzeugId, pruefungen, onAdded, canWrite }) => { const [dialogOpen, setDialogOpen] = useState(false); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); @@ -431,15 +437,17 @@ const PruefungenTab: React.FC = ({ fahrzeugId, pruefungen, o )} {/* FAB */} - { setForm(emptyForm); setDialogOpen(true); }} - > - - + {canWrite && ( + { setForm(emptyForm); setDialogOpen(true); }} + > + + + )} {/* Add inspection dialog */} setDialogOpen(false)} maxWidth="sm" fullWidth> @@ -549,6 +557,7 @@ interface WartungTabProps { fahrzeugId: string; wartungslog: FahrzeugWartungslog[]; onAdded: () => void; + canWrite: boolean; } const WARTUNG_ART_ICONS: Record = { @@ -559,7 +568,7 @@ const WARTUNG_ART_ICONS: Record = { default: , }; -const WartungTab: React.FC = ({ fahrzeugId, wartungslog, onAdded }) => { +const WartungTab: React.FC = ({ fahrzeugId, wartungslog, onAdded, canWrite }) => { const [dialogOpen, setDialogOpen] = useState(false); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); @@ -634,15 +643,17 @@ const WartungTab: React.FC = ({ fahrzeugId, wartungslog, onAdde )} - { setForm(emptyForm); setDialogOpen(true); }} - > - - + {canWrite && ( + { setForm(emptyForm); setDialogOpen(true); }} + > + + + )} setDialogOpen(false)} maxWidth="sm" fullWidth> Wartung / Service eintragen @@ -746,6 +757,7 @@ const WartungTab: React.FC = ({ fahrzeugId, wartungslog, onAdde function FahrzeugDetail() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const { isAdmin, canChangeStatus } = usePermissions(); const [vehicle, setVehicle] = useState(null); const [loading, setLoading] = useState(true); @@ -860,7 +872,7 @@ function FahrzeugDetail() { {/* Tab content */} - + @@ -868,6 +880,7 @@ function FahrzeugDetail() { fahrzeugId={vehicle.id} pruefungen={vehicle.pruefungen} onAdded={fetchVehicle} + canWrite={isAdmin} /> @@ -876,6 +889,7 @@ function FahrzeugDetail() { fahrzeugId={vehicle.id} wartungslog={vehicle.wartungslog} onAdded={fetchVehicle} + canWrite={isAdmin} /> diff --git a/frontend/src/pages/Fahrzeuge.tsx b/frontend/src/pages/Fahrzeuge.tsx index 5f75d73..4056fb0 100644 --- a/frontend/src/pages/Fahrzeuge.tsx +++ b/frontend/src/pages/Fahrzeuge.tsx @@ -38,6 +38,7 @@ import { PruefungArt, PruefungArtLabel, } from '../types/vehicle.types'; +import { usePermissions } from '../hooks/usePermissions'; // ── Status chip config ──────────────────────────────────────────────────────── @@ -87,10 +88,8 @@ const VehicleCard: React.FC = ({ vehicle, onClick }) => { // Collect inspection badges (only for types where a faellig_am exists) const inspBadges: { art: string; tage: number | null; faelligAm: string | null }[] = [ - { art: 'HU', tage: vehicle.hu_tage_bis_faelligkeit, faelligAm: vehicle.hu_faellig_am }, - { art: 'AU', tage: vehicle.au_tage_bis_faelligkeit, faelligAm: vehicle.au_faellig_am }, - { art: 'UVV', tage: vehicle.uvv_tage_bis_faelligkeit, faelligAm: vehicle.uvv_faellig_am }, - { art: 'Leiter', tage: vehicle.leiter_tage_bis_faelligkeit, faelligAm: vehicle.leiter_faellig_am }, + { art: '§57a', tage: vehicle.paragraph57a_tage_bis_faelligkeit, faelligAm: vehicle.paragraph57a_faellig_am }, + { art: 'Wartung', tage: vehicle.wartung_tage_bis_faelligkeit, faelligAm: vehicle.naechste_wartung_am }, ].filter((b) => b.faelligAm !== null); return ( @@ -218,6 +217,7 @@ const VehicleCard: React.FC = ({ vehicle, onClick }) => { function Fahrzeuge() { const navigate = useNavigate(); + const { isAdmin } = usePermissions(); const [vehicles, setVehicles] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -347,14 +347,16 @@ function Fahrzeuge() { )} {/* FAB — add vehicle (shown to write-role users only; role check done server-side) */} - navigate('/fahrzeuge/neu')} - > - - + {isAdmin && ( + navigate('/fahrzeuge/neu')} + > + + + )} ); diff --git a/frontend/src/services/vehicles.ts b/frontend/src/services/vehicles.ts index dced2d4..3085add 100644 --- a/frontend/src/services/vehicles.ts +++ b/frontend/src/services/vehicles.ts @@ -76,6 +76,10 @@ export const vehiclesApi = { return response.data.data; }, + async delete(id: string): Promise { + await api.delete(`/api/vehicles/${id}`); + }, + /** Live status change — Socket.IO event is emitted server-side in Tier 3 */ async updateStatus(id: string, payload: UpdateStatusPayload): Promise { await api.patch(`/api/vehicles/${id}/status`, payload); diff --git a/frontend/src/types/vehicle.types.ts b/frontend/src/types/vehicle.types.ts index 208ea61..5d3c33e 100644 --- a/frontend/src/types/vehicle.types.ts +++ b/frontend/src/types/vehicle.types.ts @@ -71,9 +71,13 @@ export interface FahrzeugListItem { au_tage_bis_faelligkeit: number | null; uvv_faellig_am: string | null; uvv_tage_bis_faelligkeit: number | null; - leiter_faellig_am: string | null; - leiter_tage_bis_faelligkeit: number | null; - naechste_pruefung_tage: number | null; + leiter_faellig_am: string | null; + leiter_tage_bis_faelligkeit: number | null; + paragraph57a_faellig_am: string | null; + paragraph57a_tage_bis_faelligkeit: number | null; + naechste_wartung_am: string | null; + wartung_tage_bis_faelligkeit: number | null; + naechste_pruefung_tage: number | null; } export interface PruefungStatus { @@ -129,6 +133,10 @@ export interface FahrzeugDetail { bild_url: string | null; created_at: string; updated_at: string; + paragraph57a_faellig_am: string | null; + paragraph57a_tage_bis_faelligkeit: number | null; + naechste_wartung_am: string | null; + wartung_tage_bis_faelligkeit: number | null; pruefstatus: { hu: PruefungStatus; au: PruefungStatus; @@ -174,6 +182,8 @@ export interface CreateFahrzeugPayload { status_bemerkung?: string; standort?: string; bild_url?: string; + paragraph57a_faellig_am?: string; + naechste_wartung_am?: string; } export type UpdateFahrzeugPayload = Partial;