feat: dashboard widgets, auth fix, profile names, dynamic groups

- Add VehicleDashboardCard: self-contained widget modelled after
  AtemschutzDashboardCard, shows einsatzbereit ratio and inspection
  warnings inline; replaces StatsCard + InspectionAlerts in Dashboard

- Add EquipmentDashboardCard: consolidated equipment status widget
  showing only aggregated counts (no per-item listing); replaces
  EquipmentAlerts component in Dashboard

- Fix auth race condition: add authInitialized flag to api.ts so 401
  responses during initial token validation no longer trigger a
  spurious redirect to /login; save intended destination before login
  redirect and restore it after successful auth callback

- Fix profile firstname/lastname: add extractNames() helper to
  auth.controller.ts that falls back to splitting userinfo.name when
  Authentik does not provide separate given_name/family_name fields;
  applied on both create and update paths

- Dynamic groups endpoint: replace hardcoded KNOWN_GROUPS array in
  events.controller.ts with a DB query (SELECT DISTINCT unnest
  (authentik_groups) FROM users); known slugs get German labels via
  lookup map, unknown slugs are humanized automatically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-03-03 10:28:31 +01:00
parent 831927ae90
commit 2306741c4d
11 changed files with 470 additions and 110 deletions

View File

@@ -7,6 +7,41 @@ import { AuthRequest } from '../types/auth.types';
import auditService, { AuditAction, AuditResourceType } from '../services/audit.service';
import { extractIp, extractUserAgent } from '../middleware/audit.middleware';
/**
* Extract given_name and family_name from Authentik userinfo.
* Falls back to splitting the `name` field if individual fields are missing or identical.
*/
function extractNames(userInfo: { name?: string; given_name?: string; family_name?: string }): {
given_name: string | undefined;
family_name: string | undefined;
} {
const givenName = userInfo.given_name?.trim();
const familyName = userInfo.family_name?.trim();
// If Authentik provides both and they differ, use them directly
if (givenName && familyName && givenName !== familyName) {
return { given_name: givenName, family_name: familyName };
}
// Fall back to splitting the name field
if (userInfo.name) {
const parts = userInfo.name.trim().split(/\s+/);
if (parts.length >= 2) {
return {
given_name: parts[0],
family_name: parts.slice(1).join(' '),
};
}
// Single word name — use as given_name only
return {
given_name: parts[0],
family_name: undefined,
};
}
return { given_name: givenName, family_name: familyName };
}
class AuthController {
/**
* Handle OAuth callback
@@ -56,12 +91,14 @@ class AuthController {
email: userInfo.email,
});
const { given_name: newGivenName, family_name: newFamilyName } = extractNames(userInfo);
user = await userService.createUser({
email: userInfo.email,
authentik_sub: userInfo.sub,
preferred_username: userInfo.preferred_username,
given_name: userInfo.given_name,
family_name: userInfo.family_name,
given_name: newGivenName,
family_name: newFamilyName,
name: userInfo.name,
profile_picture_url: userInfo.picture,
});
@@ -91,11 +128,13 @@ class AuthController {
await userService.updateLastLogin(user.id);
await userService.updateGroups(user.id, groups);
const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo);
// Refresh profile fields from Authentik on every login
await userService.updateUser(user.id, {
name: userInfo.name,
given_name: userInfo.given_name,
family_name: userInfo.family_name,
given_name: updatedGivenName,
family_name: updatedFamilyName,
preferred_username: userInfo.preferred_username,
});
@@ -114,6 +153,9 @@ class AuthController {
});
}
// Extract normalised names once for use in the response
const { given_name: resolvedGivenName, family_name: resolvedFamilyName } = extractNames(userInfo);
// Check if user is active
if (!user.is_active) {
logger.warn('Inactive user attempted login', { userId: user.id });
@@ -170,8 +212,8 @@ class AuthController {
email: user.email,
name: userInfo.name || user.name,
preferredUsername: userInfo.preferred_username || user.preferred_username,
givenName: userInfo.given_name || user.given_name,
familyName: userInfo.family_name || user.family_name,
givenName: resolvedGivenName || user.given_name,
familyName: resolvedFamilyName || user.family_name,
profilePictureUrl: user.profile_picture_url,
isActive: user.is_active,
groups,

View File

@@ -9,21 +9,6 @@ import {
} from '../models/events.model';
import logger from '../utils/logger';
// ---------------------------------------------------------------------------
// Known Authentik groups exposed to the frontend for event targeting
// ---------------------------------------------------------------------------
const KNOWN_GROUPS = [
{ id: 'dashboard_admin', label: 'Administratoren' },
{ id: 'dashboard_moderator', label: 'Moderatoren' },
{ id: 'dashboard_mitglied', label: 'Mitglieder' },
{ id: 'dashboard_fahrmeister', label: 'Fahrmeister' },
{ id: 'dashboard_zeugmeister', label: 'Zeugmeister' },
{ id: 'dashboard_atemschutz', label: 'Atemschutzwart' },
{ id: 'dashboard_jugend', label: 'Feuerwehrjugend' },
{ id: 'dashboard_kommandant', label: 'Kommandanten' },
];
// ---------------------------------------------------------------------------
// Helper — extract userGroups from request
// ---------------------------------------------------------------------------
@@ -124,7 +109,13 @@ class EventsController {
// GET /api/events/groups
// -------------------------------------------------------------------------
getAvailableGroups = async (_req: Request, res: Response): Promise<void> => {
res.json({ success: true, data: KNOWN_GROUPS });
try {
const groups = await eventsService.getAvailableGroups();
res.json({ success: true, data: groups });
} catch (error) {
logger.error('getAvailableGroups error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Gruppen' });
}
};
// -------------------------------------------------------------------------