featur add fahrmeister

This commit is contained in:
Matthias Hochmeister
2026-02-27 21:46:50 +01:00
parent da4a56ba6b
commit dbe4f52871
17 changed files with 426 additions and 152 deletions

View File

@@ -35,6 +35,7 @@ class AuthController {
// Step 2: Get user info from Authentik // Step 2: Get user info from Authentik
const userInfo = await authentikService.getUserInfo(tokens.access_token); const userInfo = await authentikService.getUserInfo(tokens.access_token);
const groups = userInfo.groups ?? [];
// Step 3: Verify ID token if present // Step 3: Verify ID token if present
if (tokens.id_token) { if (tokens.id_token) {
@@ -65,6 +66,8 @@ class AuthController {
profile_picture_url: userInfo.picture, profile_picture_url: userInfo.picture,
}); });
await userService.updateGroups(user.id, groups);
// Audit: first-ever login (user record creation) // Audit: first-ever login (user record creation)
auditService.logAudit({ auditService.logAudit({
user_id: user.id, user_id: user.id,
@@ -86,6 +89,7 @@ class AuthController {
}); });
await userService.updateLastLogin(user.id); await userService.updateLastLogin(user.id);
await userService.updateGroups(user.id, groups);
// Audit: returning user login // Audit: returning user login
auditService.logAudit({ auditService.logAudit({
@@ -132,6 +136,7 @@ class AuthController {
userId: user.id, userId: user.id,
email: user.email, email: user.email,
authentikSub: user.authentik_sub, authentikSub: user.authentik_sub,
groups,
}); });
// Generate refresh token // Generate refresh token
@@ -161,6 +166,7 @@ class AuthController {
familyName: user.family_name, familyName: user.family_name,
profilePictureUrl: user.profile_picture_url, profilePictureUrl: user.profile_picture_url,
isActive: user.is_active, isActive: user.is_active,
groups,
}, },
}, },
}); });

View File

@@ -29,18 +29,20 @@ const isoDate = z.string().regex(
); );
const CreateFahrzeugSchema = z.object({ const CreateFahrzeugSchema = z.object({
bezeichnung: z.string().min(1).max(100), bezeichnung: z.string().min(1).max(100),
kurzname: z.string().max(20).optional(), kurzname: z.string().max(20).optional(),
amtliches_kennzeichen: z.string().max(20).optional(), amtliches_kennzeichen: z.string().max(20).optional(),
fahrgestellnummer: z.string().max(50).optional(), fahrgestellnummer: z.string().max(50).optional(),
baujahr: z.number().int().min(1950).max(2100).optional(), baujahr: z.number().int().min(1950).max(2100).optional(),
hersteller: z.string().max(100).optional(), hersteller: z.string().max(100).optional(),
typ_schluessel: z.string().max(30).optional(), typ_schluessel: z.string().max(30).optional(),
besatzung_soll: z.string().max(10).optional(), besatzung_soll: z.string().max(10).optional(),
status: FahrzeugStatusEnum.optional(), status: FahrzeugStatusEnum.optional(),
status_bemerkung: z.string().max(500).optional(), status_bemerkung: z.string().max(500).optional(),
standort: z.string().max(100).optional(), standort: z.string().max(100).optional(),
bild_url: z.string().url().max(500).optional(), bild_url: z.string().url().max(500).optional(),
paragraph57a_faellig_am: isoDate.optional(),
naechste_wartung_am: isoDate.optional(),
}); });
const UpdateFahrzeugSchema = CreateFahrzeugSchema.partial(); 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<void> {
try {
const { id } = req.params as Record<string, string>;
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 * GET /api/vehicles/:id/wartung
* Maintenance log for a vehicle. * Maintenance log for a vehicle.

View File

@@ -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';

View File

@@ -43,6 +43,7 @@ declare global {
email: string; email: string;
authentikSub: string; authentikSub: string;
role?: AppRole; // populated when role is stored in DB / JWT role?: AppRole; // populated when role is stored in DB / JWT
groups?: string[];
}; };
} }
} }
@@ -120,6 +121,7 @@ export const authenticate = async (
id: decoded.userId, id: decoded.userId,
email: decoded.email, email: decoded.email,
authentikSub: decoded.authentikSub, authentikSub: decoded.authentikSub,
groups: decoded.groups ?? [],
}; };
logger.debug('User authenticated successfully', { logger.debug('User authenticated successfully', {
@@ -225,6 +227,7 @@ export const optionalAuth = async (
id: decoded.userId, id: decoded.userId,
email: decoded.email, email: decoded.email,
authentikSub: decoded.authentikSub, authentikSub: decoded.authentikSub,
groups: decoded.groups ?? [],
}; };
} }
} catch (error) { } catch (error) {

View File

@@ -134,3 +134,44 @@ export function requirePermission(permission: string) {
} }
export { getUserRole, hasPermission }; 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<void> => {
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();
};
}

View File

@@ -14,6 +14,7 @@ export interface User {
created_at: Date; created_at: Date;
updated_at: Date; updated_at: Date;
preferences?: any; // JSONB preferences?: any; // JSONB
authentik_groups: string[];
} }
export interface CreateUserData { export interface CreateUserData {
@@ -24,6 +25,7 @@ export interface CreateUserData {
given_name?: string; given_name?: string;
family_name?: string; family_name?: string;
profile_picture_url?: string; profile_picture_url?: string;
authentik_groups?: string[];
} }
export interface UpdateUserData { export interface UpdateUserData {

View File

@@ -83,21 +83,25 @@ export type WartungslogArt =
/** Raw database row from the `fahrzeuge` table */ /** Raw database row from the `fahrzeuge` table */
export interface Fahrzeug { export interface Fahrzeug {
id: string; // UUID id: string; // UUID
bezeichnung: string; // e.g. "LF 20/16" bezeichnung: string; // e.g. "LF 20/16"
kurzname: string | null; kurzname: string | null;
amtliches_kennzeichen: string | null; amtliches_kennzeichen: string | null;
fahrgestellnummer: string | null; fahrgestellnummer: string | null;
baujahr: number | null; baujahr: number | null;
hersteller: string | null; hersteller: string | null;
typ_schluessel: string | null; typ_schluessel: string | null;
besatzung_soll: string | null; // e.g. "1/8" besatzung_soll: string | null; // e.g. "1/8"
status: FahrzeugStatus; status: FahrzeugStatus;
status_bemerkung: string | null; status_bemerkung: string | null;
standort: string; standort: string;
bild_url: string | null; bild_url: string | null;
created_at: Date; /** §57a StVO periodic inspection due date (Austrian equivalent of HU/TÜV) */
updated_at: Date; 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` */ /** Raw database row from `fahrzeug_pruefungen` */
@@ -153,6 +157,10 @@ export interface FahrzeugWithPruefstatus extends Fahrzeug {
uvv: PruefungStatus; uvv: PruefungStatus;
leiter: 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) */ /** Minimum tage_bis_faelligkeit across all inspections (negative = any overdue) */
naechste_pruefung_tage: number | null; naechste_pruefung_tage: number | null;
/** Full inspection history, ordered by faellig_am DESC */ /** 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. * Includes only the fields needed to render a card plus inspection badges.
*/ */
export interface FahrzeugListItem { export interface FahrzeugListItem {
id: string; id: string;
bezeichnung: string; bezeichnung: string;
kurzname: string | null; kurzname: string | null;
amtliches_kennzeichen: string | null; amtliches_kennzeichen: string | null;
baujahr: number | null; baujahr: number | null;
hersteller: string | null; hersteller: string | null;
besatzung_soll: string | null; besatzung_soll: string | null;
status: FahrzeugStatus; status: FahrzeugStatus;
status_bemerkung: string | null; status_bemerkung: string | null;
bild_url: string | null; bild_url: string | null;
hu_faellig_am: Date | null; /** §57a due date (primary inspection badge) */
hu_tage_bis_faelligkeit: number | null; paragraph57a_faellig_am: Date | null;
au_faellig_am: Date | null; paragraph57a_tage_bis_faelligkeit: number | null;
au_tage_bis_faelligkeit: number | null; /** Next service due date */
uvv_faellig_am: Date | null; naechste_wartung_am: Date | null;
uvv_tage_bis_faelligkeit: number | null; wartung_tage_bis_faelligkeit: number | null;
leiter_faellig_am: Date | 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; leiter_tage_bis_faelligkeit: number | null;
naechste_pruefung_tage: number | null; naechste_pruefung_tage: number | null;
} }
// ── Dashboard KPI ───────────────────────────────────────────────────────────── // ── Dashboard KPI ─────────────────────────────────────────────────────────────
@@ -229,21 +244,25 @@ export interface CreateFahrzeugData {
status_bemerkung?: string; status_bemerkung?: string;
standort?: string; standort?: string;
bild_url?: 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 { export interface UpdateFahrzeugData {
bezeichnung?: string; bezeichnung?: string;
kurzname?: string | null; kurzname?: string | null;
amtliches_kennzeichen?: string | null; amtliches_kennzeichen?: string | null;
fahrgestellnummer?: string | null; fahrgestellnummer?: string | null;
baujahr?: number | null; baujahr?: number | null;
hersteller?: string | null; hersteller?: string | null;
typ_schluessel?: string | null; typ_schluessel?: string | null;
besatzung_soll?: string | null; besatzung_soll?: string | null;
status?: FahrzeugStatus; status?: FahrzeugStatus;
status_bemerkung?: string | null; status_bemerkung?: string | null;
standort?: string; standort?: string;
bild_url?: string | null; 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 { export interface CreatePruefungData {

View File

@@ -1,49 +1,10 @@
import { Router } from 'express'; import { Router } from 'express';
import vehicleController from '../controllers/vehicle.controller'; import vehicleController from '../controllers/vehicle.controller';
import { authenticate } from '../middleware/auth.middleware'; import { authenticate } from '../middleware/auth.middleware';
import { requireGroups } from '../middleware/rbac.middleware';
// --------------------------------------------------------------------------- const ADMIN_GROUPS = ['dashboard_admin'];
// RBAC guard — requirePermission('vehicles:write') const STATUS_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister'];
// ---------------------------------------------------------------------------
// 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 router = Router(); const router = Router();
@@ -86,7 +47,7 @@ router.get('/:id/pruefungen', authenticate, vehicleController.getPruefungen.bind
*/ */
router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController)); router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController));
// ── Write endpoints (vehicles:write role required) ───────────────────────────── // ── Write endpoints (dashboard_admin group required) ────────────────────────
/** /**
* POST /api/vehicles * POST /api/vehicles
@@ -95,7 +56,7 @@ router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehic
router.post( router.post(
'/', '/',
authenticate, authenticate,
requireVehicleWrite, requireGroups(ADMIN_GROUPS),
vehicleController.createVehicle.bind(vehicleController) vehicleController.createVehicle.bind(vehicleController)
); );
@@ -106,19 +67,19 @@ router.post(
router.patch( router.patch(
'/:id', '/:id',
authenticate, authenticate,
requireVehicleWrite, requireGroups(ADMIN_GROUPS),
vehicleController.updateVehicle.bind(vehicleController) vehicleController.updateVehicle.bind(vehicleController)
); );
/** /**
* PATCH /api/vehicles/:id/status * 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'). * The `io` instance is retrieved inside the controller via req.app.get('io').
*/ */
router.patch( router.patch(
'/:id/status', '/:id/status',
authenticate, authenticate,
requireVehicleWrite, requireGroups(STATUS_GROUPS),
vehicleController.updateVehicleStatus.bind(vehicleController) vehicleController.updateVehicleStatus.bind(vehicleController)
); );
@@ -129,7 +90,7 @@ router.patch(
router.post( router.post(
'/:id/pruefungen', '/:id/pruefungen',
authenticate, authenticate,
requireVehicleWrite, requireGroups(ADMIN_GROUPS),
vehicleController.addPruefung.bind(vehicleController) vehicleController.addPruefung.bind(vehicleController)
); );
@@ -140,8 +101,20 @@ router.post(
router.post( router.post(
'/:id/wartung', '/:id/wartung',
authenticate, authenticate,
requireVehicleWrite, requireGroups(ADMIN_GROUPS),
vehicleController.addWartung.bind(vehicleController) 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; export default router;

View File

@@ -14,6 +14,7 @@ class TokenService {
userId: payload.userId, userId: payload.userId,
email: payload.email, email: payload.email,
authentikSub: payload.authentikSub, authentikSub: payload.authentikSub,
groups: payload.groups ?? [],
}, },
environment.jwt.secret, environment.jwt.secret,
{ {

View File

@@ -11,7 +11,7 @@ class UserService {
const query = ` const query = `
SELECT id, email, authentik_sub, name, preferred_username, given_name, SELECT id, email, authentik_sub, name, preferred_username, given_name,
family_name, profile_picture_url, refresh_token, refresh_token_expires_at, 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 FROM users
WHERE authentik_sub = $1 WHERE authentik_sub = $1
`; `;
@@ -39,7 +39,7 @@ class UserService {
const query = ` const query = `
SELECT id, email, authentik_sub, name, preferred_username, given_name, SELECT id, email, authentik_sub, name, preferred_username, given_name,
family_name, profile_picture_url, refresh_token, refresh_token_expires_at, 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 FROM users
WHERE email = $1 WHERE email = $1
`; `;
@@ -67,7 +67,7 @@ class UserService {
const query = ` const query = `
SELECT id, email, authentik_sub, name, preferred_username, given_name, SELECT id, email, authentik_sub, name, preferred_username, given_name,
family_name, profile_picture_url, refresh_token, refresh_token_expires_at, 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 FROM users
WHERE id = $1 WHERE id = $1
`; `;
@@ -101,12 +101,13 @@ class UserService {
given_name, given_name,
family_name, family_name,
profile_picture_url, 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, RETURNING id, email, authentik_sub, name, preferred_username, given_name,
family_name, profile_picture_url, refresh_token, refresh_token_expires_at, 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 = [ const values = [
@@ -117,6 +118,7 @@ class UserService {
userData.given_name || null, userData.given_name || null,
userData.family_name || null, userData.family_name || null,
userData.profile_picture_url || null, userData.profile_picture_url || null,
userData.authentik_groups ?? [],
]; ];
const result = await pool.query(query, values); const result = await pool.query(query, values);
@@ -185,7 +187,7 @@ class UserService {
WHERE id = $${paramCount} WHERE id = $${paramCount}
RETURNING id, email, authentik_sub, name, preferred_username, given_name, RETURNING id, email, authentik_sub, name, preferred_username, given_name,
family_name, profile_picture_url, refresh_token, refresh_token_expires_at, 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); const result = await pool.query(query, values);
@@ -270,6 +272,22 @@ class UserService {
return false; return false;
} }
} }
/**
* Sync Authentik groups for a user
*/
async updateGroups(id: string, groups: string[]): Promise<void> {
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(); export default new UserService();

View File

@@ -63,6 +63,10 @@ class VehicleService {
status, status,
status_bemerkung, status_bemerkung,
bild_url, bild_url,
paragraph57a_faellig_am,
paragraph57a_tage_bis_faelligkeit,
naechste_wartung_am,
wartung_tage_bis_faelligkeit,
hu_faellig_am, hu_faellig_am,
hu_tage_bis_faelligkeit, hu_tage_bis_faelligkeit,
au_faellig_am, au_faellig_am,
@@ -78,6 +82,10 @@ class VehicleService {
return result.rows.map((row) => ({ return result.rows.map((row) => ({
...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 hu_tage_bis_faelligkeit: row.hu_tage_bis_faelligkeit != null
? parseInt(row.hu_tage_bis_faelligkeit, 10) : null, ? parseInt(row.hu_tage_bis_faelligkeit, 10) : null,
au_tage_bis_faelligkeit: row.au_tage_bis_faelligkeit != null au_tage_bis_faelligkeit: row.au_tage_bis_faelligkeit != null
@@ -145,6 +153,8 @@ class VehicleService {
status_bemerkung: row.status_bemerkung, status_bemerkung: row.status_bemerkung,
standort: row.standort, standort: row.standort,
bild_url: row.bild_url, 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, created_at: row.created_at,
updated_at: row.updated_at, updated_at: row.updated_at,
pruefstatus: { pruefstatus: {
@@ -153,6 +163,10 @@ class VehicleService {
uvv: mapPruefungStatus(row, 'uvv'), uvv: mapPruefungStatus(row, 'uvv'),
leiter: mapPruefungStatus(row, 'leiter'), 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 naechste_pruefung_tage: row.naechste_pruefung_tage != null
? parseInt(row.naechste_pruefung_tage, 10) : null, ? parseInt(row.naechste_pruefung_tage, 10) : null,
pruefungen: pruefungenResult.rows as FahrzeugPruefung[], pruefungen: pruefungenResult.rows as FahrzeugPruefung[],
@@ -179,8 +193,9 @@ class VehicleService {
`INSERT INTO fahrzeuge ( `INSERT INTO fahrzeuge (
bezeichnung, kurzname, amtliches_kennzeichen, fahrgestellnummer, bezeichnung, kurzname, amtliches_kennzeichen, fahrgestellnummer,
baujahr, hersteller, typ_schluessel, besatzung_soll, baujahr, hersteller, typ_schluessel, besatzung_soll,
status, status_bemerkung, standort, bild_url status, status_bemerkung, standort, bild_url,
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) paragraph57a_faellig_am, naechste_wartung_am
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
RETURNING *`, RETURNING *`,
[ [
data.bezeichnung, data.bezeichnung,
@@ -195,6 +210,8 @@ class VehicleService {
data.status_bemerkung ?? null, data.status_bemerkung ?? null,
data.standort ?? 'Feuerwehrhaus', data.standort ?? 'Feuerwehrhaus',
data.bild_url ?? null, 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.status_bemerkung !== undefined) addField('status_bemerkung', data.status_bemerkung);
if (data.standort !== undefined) addField('standort', data.standort); if (data.standort !== undefined) addField('standort', data.standort);
if (data.bild_url !== undefined) addField('bild_url', data.bild_url); 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) { if (fields.length === 0) {
throw new Error('No fields to update'); throw new Error('No fields to update');
@@ -258,6 +277,24 @@ class VehicleService {
} }
} }
async deleteVehicle(id: string, deletedBy: string): Promise<void> {
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 // STATUS MANAGEMENT
// Socket.io-ready: accepts optional `io` parameter. // Socket.io-ready: accepts optional `io` parameter.

View File

@@ -27,6 +27,7 @@ export interface JwtPayload {
userId: string; // UUID userId: string; // UUID
email: string; email: string;
authentikSub: string; authentikSub: string;
groups?: string[];
iat?: number; iat?: number;
exp?: number; exp?: number;
} }

View File

@@ -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,
};
}

View File

@@ -62,6 +62,7 @@ import {
WartungslogArt, WartungslogArt,
PruefungErgebnis, PruefungErgebnis,
} from '../types/vehicle.types'; } from '../types/vehicle.types';
import { usePermissions } from '../hooks/usePermissions';
// ── Tab Panel ───────────────────────────────────────────────────────────────── // ── Tab Panel ─────────────────────────────────────────────────────────────────
@@ -114,9 +115,10 @@ function inspectionBadgeColor(tage: number | null): 'success' | 'warning' | 'err
interface UebersichtTabProps { interface UebersichtTabProps {
vehicle: FahrzeugDetail; vehicle: FahrzeugDetail;
onStatusUpdated: () => void; onStatusUpdated: () => void;
canChangeStatus: boolean;
} }
const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated }) => { const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated, canChangeStatus }) => {
const [statusDialogOpen, setStatusDialogOpen] = useState(false); const [statusDialogOpen, setStatusDialogOpen] = useState(false);
const [newStatus, setNewStatus] = useState<FahrzeugStatus>(vehicle.status); const [newStatus, setNewStatus] = useState<FahrzeugStatus>(vehicle.status);
const [bemerkung, setBemerkung] = useState(vehicle.status_bemerkung ?? ''); const [bemerkung, setBemerkung] = useState(vehicle.status_bemerkung ?? '');
@@ -177,6 +179,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated
setBemerkung(vehicle.status_bemerkung ?? ''); setBemerkung(vehicle.status_bemerkung ?? '');
setStatusDialogOpen(true); setStatusDialogOpen(true);
}} }}
sx={{ display: canChangeStatus ? undefined : 'none' }}
> >
Status ändern Status ändern
</Button> </Button>
@@ -195,6 +198,8 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated
{ label: 'Typ (DIN 14502)', value: vehicle.typ_schluessel }, { label: 'Typ (DIN 14502)', value: vehicle.typ_schluessel },
{ label: 'Besatzung (Soll)', value: vehicle.besatzung_soll }, { label: 'Besatzung (Soll)', value: vehicle.besatzung_soll },
{ label: 'Standort', value: vehicle.standort }, { 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 }) => ( ].map(({ label, value }) => (
<Grid item xs={12} sm={6} md={4} key={label}> <Grid item xs={12} sm={6} md={4} key={label}>
<Typography variant="caption" color="text.secondary" textTransform="uppercase"> <Typography variant="caption" color="text.secondary" textTransform="uppercase">
@@ -311,6 +316,7 @@ interface PruefungenTabProps {
fahrzeugId: string; fahrzeugId: string;
pruefungen: FahrzeugPruefung[]; pruefungen: FahrzeugPruefung[];
onAdded: () => void; onAdded: () => void;
canWrite: boolean;
} }
const ERGEBNIS_LABELS: Record<PruefungErgebnis, string> = { const ERGEBNIS_LABELS: Record<PruefungErgebnis, string> = {
@@ -327,7 +333,7 @@ const ERGEBNIS_COLORS: Record<PruefungErgebnis, 'success' | 'warning' | 'error'
ausstehend: 'default', ausstehend: 'default',
}; };
const PruefungenTab: React.FC<PruefungenTabProps> = ({ fahrzeugId, pruefungen, onAdded }) => { const PruefungenTab: React.FC<PruefungenTabProps> = ({ fahrzeugId, pruefungen, onAdded, canWrite }) => {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
@@ -431,15 +437,17 @@ const PruefungenTab: React.FC<PruefungenTabProps> = ({ fahrzeugId, pruefungen, o
)} )}
{/* FAB */} {/* FAB */}
<Fab {canWrite && (
color="primary" <Fab
size="small" color="primary"
aria-label="Prüfung hinzufügen" size="small"
sx={{ position: 'fixed', bottom: 32, right: 32 }} aria-label="Prüfung hinzufügen"
onClick={() => { setForm(emptyForm); setDialogOpen(true); }} sx={{ position: 'fixed', bottom: 32, right: 32 }}
> onClick={() => { setForm(emptyForm); setDialogOpen(true); }}
<Add /> >
</Fab> <Add />
</Fab>
)}
{/* Add inspection dialog */} {/* Add inspection dialog */}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth> <Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
@@ -549,6 +557,7 @@ interface WartungTabProps {
fahrzeugId: string; fahrzeugId: string;
wartungslog: FahrzeugWartungslog[]; wartungslog: FahrzeugWartungslog[];
onAdded: () => void; onAdded: () => void;
canWrite: boolean;
} }
const WARTUNG_ART_ICONS: Record<string, React.ReactElement> = { const WARTUNG_ART_ICONS: Record<string, React.ReactElement> = {
@@ -559,7 +568,7 @@ const WARTUNG_ART_ICONS: Record<string, React.ReactElement> = {
default: <Build color="action" />, default: <Build color="action" />,
}; };
const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdded }) => { const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdded, canWrite }) => {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
@@ -634,15 +643,17 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
</Stack> </Stack>
)} )}
<Fab {canWrite && (
color="primary" <Fab
size="small" color="primary"
aria-label="Wartung eintragen" size="small"
sx={{ position: 'fixed', bottom: 32, right: 32 }} aria-label="Wartung eintragen"
onClick={() => { setForm(emptyForm); setDialogOpen(true); }} sx={{ position: 'fixed', bottom: 32, right: 32 }}
> onClick={() => { setForm(emptyForm); setDialogOpen(true); }}
<Add /> >
</Fab> <Add />
</Fab>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth> <Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Wartung / Service eintragen</DialogTitle> <DialogTitle>Wartung / Service eintragen</DialogTitle>
@@ -746,6 +757,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
function FahrzeugDetail() { function FahrzeugDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { isAdmin, canChangeStatus } = usePermissions();
const [vehicle, setVehicle] = useState<FahrzeugDetail | null>(null); const [vehicle, setVehicle] = useState<FahrzeugDetail | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -860,7 +872,7 @@ function FahrzeugDetail() {
{/* Tab content */} {/* Tab content */}
<TabPanel value={activeTab} index={0}> <TabPanel value={activeTab} index={0}>
<UebersichtTab vehicle={vehicle} onStatusUpdated={fetchVehicle} /> <UebersichtTab vehicle={vehicle} onStatusUpdated={fetchVehicle} canChangeStatus={canChangeStatus} />
</TabPanel> </TabPanel>
<TabPanel value={activeTab} index={1}> <TabPanel value={activeTab} index={1}>
@@ -868,6 +880,7 @@ function FahrzeugDetail() {
fahrzeugId={vehicle.id} fahrzeugId={vehicle.id}
pruefungen={vehicle.pruefungen} pruefungen={vehicle.pruefungen}
onAdded={fetchVehicle} onAdded={fetchVehicle}
canWrite={isAdmin}
/> />
</TabPanel> </TabPanel>
@@ -876,6 +889,7 @@ function FahrzeugDetail() {
fahrzeugId={vehicle.id} fahrzeugId={vehicle.id}
wartungslog={vehicle.wartungslog} wartungslog={vehicle.wartungslog}
onAdded={fetchVehicle} onAdded={fetchVehicle}
canWrite={isAdmin}
/> />
</TabPanel> </TabPanel>

View File

@@ -38,6 +38,7 @@ import {
PruefungArt, PruefungArt,
PruefungArtLabel, PruefungArtLabel,
} from '../types/vehicle.types'; } from '../types/vehicle.types';
import { usePermissions } from '../hooks/usePermissions';
// ── Status chip config ──────────────────────────────────────────────────────── // ── Status chip config ────────────────────────────────────────────────────────
@@ -87,10 +88,8 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
// Collect inspection badges (only for types where a faellig_am exists) // Collect inspection badges (only for types where a faellig_am exists)
const inspBadges: { art: string; tage: number | null; faelligAm: string | null }[] = [ const inspBadges: { art: string; tage: number | null; faelligAm: string | null }[] = [
{ art: 'HU', tage: vehicle.hu_tage_bis_faelligkeit, faelligAm: vehicle.hu_faellig_am }, { art: '§57a', tage: vehicle.paragraph57a_tage_bis_faelligkeit, faelligAm: vehicle.paragraph57a_faellig_am },
{ art: 'AU', tage: vehicle.au_tage_bis_faelligkeit, faelligAm: vehicle.au_faellig_am }, { art: 'Wartung', tage: vehicle.wartung_tage_bis_faelligkeit, faelligAm: vehicle.naechste_wartung_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 },
].filter((b) => b.faelligAm !== null); ].filter((b) => b.faelligAm !== null);
return ( return (
@@ -218,6 +217,7 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
function Fahrzeuge() { function Fahrzeuge() {
const navigate = useNavigate(); const navigate = useNavigate();
const { isAdmin } = usePermissions();
const [vehicles, setVehicles] = useState<FahrzeugListItem[]>([]); const [vehicles, setVehicles] = useState<FahrzeugListItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -347,14 +347,16 @@ function Fahrzeuge() {
)} )}
{/* FAB — add vehicle (shown to write-role users only; role check done server-side) */} {/* FAB — add vehicle (shown to write-role users only; role check done server-side) */}
<Fab {isAdmin && (
color="primary" <Fab
aria-label="Fahrzeug hinzufügen" color="primary"
sx={{ position: 'fixed', bottom: 32, right: 32 }} aria-label="Fahrzeug hinzufügen"
onClick={() => navigate('/fahrzeuge/neu')} sx={{ position: 'fixed', bottom: 32, right: 32 }}
> onClick={() => navigate('/fahrzeuge/neu')}
<Add /> >
</Fab> <Add />
</Fab>
)}
</Container> </Container>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -76,6 +76,10 @@ export const vehiclesApi = {
return response.data.data; return response.data.data;
}, },
async delete(id: string): Promise<void> {
await api.delete(`/api/vehicles/${id}`);
},
/** Live status change — Socket.IO event is emitted server-side in Tier 3 */ /** Live status change — Socket.IO event is emitted server-side in Tier 3 */
async updateStatus(id: string, payload: UpdateStatusPayload): Promise<void> { async updateStatus(id: string, payload: UpdateStatusPayload): Promise<void> {
await api.patch(`/api/vehicles/${id}/status`, payload); await api.patch(`/api/vehicles/${id}/status`, payload);

View File

@@ -71,9 +71,13 @@ export interface FahrzeugListItem {
au_tage_bis_faelligkeit: number | null; au_tage_bis_faelligkeit: number | null;
uvv_faellig_am: string | null; uvv_faellig_am: string | null;
uvv_tage_bis_faelligkeit: number | null; uvv_tage_bis_faelligkeit: number | null;
leiter_faellig_am: string | null; leiter_faellig_am: string | null;
leiter_tage_bis_faelligkeit: number | null; leiter_tage_bis_faelligkeit: number | null;
naechste_pruefung_tage: 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 { export interface PruefungStatus {
@@ -129,6 +133,10 @@ export interface FahrzeugDetail {
bild_url: string | null; bild_url: string | null;
created_at: string; created_at: string;
updated_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: { pruefstatus: {
hu: PruefungStatus; hu: PruefungStatus;
au: PruefungStatus; au: PruefungStatus;
@@ -174,6 +182,8 @@ export interface CreateFahrzeugPayload {
status_bemerkung?: string; status_bemerkung?: string;
standort?: string; standort?: string;
bild_url?: string; bild_url?: string;
paragraph57a_faellig_am?: string;
naechste_wartung_am?: string;
} }
export type UpdateFahrzeugPayload = Partial<CreateFahrzeugPayload>; export type UpdateFahrzeugPayload = Partial<CreateFahrzeugPayload>;