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:
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -430,6 +430,48 @@ class EventsService {
|
||||
return { token, subscribeUrl };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GROUPS
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns distinct group slugs from active users as { id, label } pairs.
|
||||
* The label is derived from a known-translations map or humanized from the slug.
|
||||
*/
|
||||
async getAvailableGroups(): Promise<Array<{ id: string; label: string }>> {
|
||||
const knownLabels: Record<string, string> = {
|
||||
'dashboard_admin': 'Administratoren',
|
||||
'dashboard_mitglied': 'Mitglieder',
|
||||
'dashboard_fahrmeister': 'Fahrmeister',
|
||||
'dashboard_zeugmeister': 'Zeugmeister',
|
||||
'dashboard_atemschutz': 'Atemschutzwart',
|
||||
'dashboard_jugend': 'Feuerwehrjugend',
|
||||
'dashboard_kommandant': 'Kommandanten',
|
||||
'dashboard_moderator': 'Moderatoren',
|
||||
'feuerwehr-admin': 'Feuerwehr Admin',
|
||||
'feuerwehr-kommandant': 'Feuerwehr Kommandant',
|
||||
};
|
||||
|
||||
const humanizeGroupName = (slug: string): string => {
|
||||
if (knownLabels[slug]) return knownLabels[slug];
|
||||
const stripped = slug.startsWith('dashboard_') ? slug.slice('dashboard_'.length) : slug;
|
||||
const spaced = stripped.replace(/-/g, ' ');
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
};
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT DISTINCT unnest(authentik_groups) AS group_name
|
||||
FROM users
|
||||
WHERE is_active = true
|
||||
ORDER BY group_name`
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
id: row.group_name as string,
|
||||
label: humanizeGroupName(row.group_name as string),
|
||||
}));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ICAL EXPORT
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user