feat: user data purge, breadcrumbs, first-login dialog, widget consolidation, bookkeeping cascade
- Admin can purge all personal data for a user (POST /api/admin/users/:userId/purge-data) while keeping the account; clears profile, notifications, bookings, ical tokens, preferences - Add isNewUser flag to auth callback response; first-login dialog prompts for Standesbuchnummer - Add PageBreadcrumbs component and apply to 18 sub-pages across the app - Cascade budget_typ changes from parent pot to all children recursively, converting amounts (detailliert→einfach: sum into budget_gesamt; einfach→detailliert: zero all for redistribution) - Migrate NextcloudTalkWidget to use shared WidgetCard template for consistent header styling Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -100,6 +100,7 @@ class AuthController {
|
||||
|
||||
// Step 4: Find or create user in database
|
||||
let user = await userService.findByAuthentikSub(userInfo.sub);
|
||||
const isNewUser = !user;
|
||||
|
||||
if (!user) {
|
||||
// User doesn't exist, create new user
|
||||
@@ -230,6 +231,7 @@ class AuthController {
|
||||
data: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
isNewUser,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { requirePermission } from '../middleware/rbac.middleware';
|
||||
import { auditExport } from '../middleware/audit.middleware';
|
||||
import auditService, { AuditAction, AuditResourceType, AuditFilters } from '../services/audit.service';
|
||||
import cleanupService from '../services/cleanup.service';
|
||||
import userService from '../services/user.service';
|
||||
import pool from '../config/database';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
@@ -440,4 +441,53 @@ router.delete(
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/admin/users/:userId/purge-data — delete all user-associated data
|
||||
// (keeps the users record, financial records, and audit log)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.post(
|
||||
'/users/:userId/purge-data',
|
||||
authenticate,
|
||||
requirePermission('admin:write'),
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const targetUserId = req.params.userId as string;
|
||||
const requestingUserId = req.user!.id;
|
||||
|
||||
const targetUser = await userService.findById(targetUserId);
|
||||
if (!targetUser) {
|
||||
res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await userService.purgeUserData(targetUserId, requestingUserId);
|
||||
|
||||
// Audit log
|
||||
await auditService.logAudit({
|
||||
user_id: requestingUserId,
|
||||
user_email: req.user!.email,
|
||||
action: AuditAction.DELETE,
|
||||
resource_type: AuditResourceType.USER,
|
||||
resource_id: targetUserId,
|
||||
old_value: { email: targetUser.email, name: targetUser.name },
|
||||
new_value: { purged_tables: result },
|
||||
ip_address: req.ip ?? null,
|
||||
user_agent: req.headers['user-agent'] ?? null,
|
||||
metadata: { operation: 'purge_user_data' },
|
||||
});
|
||||
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Fehler beim Löschen der Benutzerdaten';
|
||||
if (message === 'Cannot purge your own data') {
|
||||
res.status(400).json({ success: false, message: 'Eigene Daten können nicht gelöscht werden' });
|
||||
return;
|
||||
}
|
||||
logger.error('Failed to purge user data', { error, userId: req.params.userId });
|
||||
res.status(500).json({ success: false, message: 'Fehler beim Löschen der Benutzerdaten' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -653,6 +653,36 @@ async function createKonto(
|
||||
}
|
||||
}
|
||||
|
||||
async function cascadeBudgetTypToChildren(parentId: number, newType: string): Promise<void> {
|
||||
const children = await pool.query(
|
||||
'SELECT id, budget_typ, budget_gwg, budget_anlagen, budget_instandhaltung, budget_gesamt FROM buchhaltung_konten WHERE parent_id = $1',
|
||||
[parentId]
|
||||
);
|
||||
|
||||
for (const child of children.rows) {
|
||||
const oldType = child.budget_typ || 'detailliert';
|
||||
if (oldType === newType) continue;
|
||||
|
||||
let gwg = 0, anlagen = 0, instandhaltung = 0, gesamt = 0;
|
||||
|
||||
if (oldType === 'detailliert' && newType === 'einfach') {
|
||||
// Sum detail fields into gesamt
|
||||
gesamt = (parseFloat(child.budget_gwg) || 0) + (parseFloat(child.budget_anlagen) || 0) + (parseFloat(child.budget_instandhaltung) || 0);
|
||||
}
|
||||
// einfach → detailliert: zero everything (user redistributes manually)
|
||||
|
||||
await pool.query(
|
||||
`UPDATE buchhaltung_konten
|
||||
SET budget_typ = $1, budget_gwg = $2, budget_anlagen = $3, budget_instandhaltung = $4, budget_gesamt = $5
|
||||
WHERE id = $6`,
|
||||
[newType, gwg, anlagen, instandhaltung, gesamt, child.id]
|
||||
);
|
||||
|
||||
// Recurse into grandchildren
|
||||
await cascadeBudgetTypToChildren(child.id, newType);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateKonto(
|
||||
id: number,
|
||||
data: { konto_typ_id?: number; kontonummer?: string; bezeichnung?: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string; kategorie_id?: number | null; budget_typ?: string; budget_gesamt?: number }
|
||||
@@ -711,6 +741,15 @@ async function updateKonto(
|
||||
`UPDATE buchhaltung_konten SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
// Cascade budget_typ change to all children recursively
|
||||
if (data.budget_typ !== undefined && result.rows[0]) {
|
||||
const newBudgetTyp = result.rows[0].budget_typ;
|
||||
// Fetch what the old type was before the update (the RETURNING row has the new value)
|
||||
// We know a change happened if the user sent budget_typ, so cascade unconditionally
|
||||
await cascadeBudgetTypToChildren(id, newBudgetTyp);
|
||||
}
|
||||
|
||||
return result.rows[0] || null;
|
||||
} catch (error: any) {
|
||||
logger.error('BuchhaltungService.updateKonto failed', { error, id });
|
||||
|
||||
@@ -322,6 +322,69 @@ class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge all data associated with a user, keeping the users record itself.
|
||||
* Financial/audit records (buchhaltung, bestellung_historie, audit_log) are preserved.
|
||||
*/
|
||||
async purgeUserData(userId: string, requestingUserId: string): Promise<Record<string, number>> {
|
||||
if (userId === requestingUserId) {
|
||||
throw new Error('Cannot purge your own data');
|
||||
}
|
||||
|
||||
const user = await this.findById(userId);
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const results: Record<string, number> = {};
|
||||
|
||||
// Helper to delete and track row counts
|
||||
const purge = async (table: string, column = 'user_id') => {
|
||||
const r = await client.query(`DELETE FROM ${table} WHERE ${column} = $1`, [userId]);
|
||||
results[table] = r.rowCount ?? 0;
|
||||
};
|
||||
|
||||
// User-owned data tables (DELETE rows)
|
||||
await purge('notifications');
|
||||
await purge('mitglieder_profile');
|
||||
await purge('dienstgrad_verlauf');
|
||||
await purge('atemschutz_traeger');
|
||||
await purge('ausbildungen');
|
||||
await purge('untersuchungen');
|
||||
await purge('fahrgenehmigungen');
|
||||
await purge('befoerderungen');
|
||||
await purge('einsatz_personal');
|
||||
await purge('uebung_teilnahmen');
|
||||
await purge('veranstaltung_teilnahmen');
|
||||
await purge('veranstaltung_ical_tokens');
|
||||
await purge('fahrzeug_ical_tokens');
|
||||
await purge('shop_anfragen', 'anfrager_id');
|
||||
|
||||
// Clear user preferences (widget layout, etc.)
|
||||
await client.query(
|
||||
`UPDATE users SET preferences = NULL, nextcloud_login_name = NULL, nextcloud_app_password = NULL WHERE id = $1`,
|
||||
[userId]
|
||||
);
|
||||
results['preferences_cleared'] = 1;
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
logger.info('User data purged', { userId, by: requestingUserId, results });
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error('Error purging user data', { error, userId, requestingUserId });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Authentik groups for a user
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -11,11 +18,14 @@ import {
|
||||
Paper,
|
||||
TextField,
|
||||
Chip,
|
||||
Tooltip,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DeleteSweep as DeleteSweepIcon } from '@mui/icons-material';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { adminApi } from '../../services/admin';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import type { UserOverview } from '../../types/admin.types';
|
||||
|
||||
function getRoleFromGroups(groups: string[] | null): string {
|
||||
@@ -51,6 +61,10 @@ function UserOverviewTab() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('name');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
||||
const [purgeTarget, setPurgeTarget] = useState<UserOverview | null>(null);
|
||||
const [purging, setPurging] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
|
||||
const { data: users, isLoading, isError } = useQuery({
|
||||
queryKey: ['admin', 'users'],
|
||||
@@ -90,6 +104,21 @@ function UserOverviewTab() {
|
||||
return result;
|
||||
}, [users, search, sortKey, sortDir]);
|
||||
|
||||
const handlePurge = async () => {
|
||||
if (!purgeTarget) return;
|
||||
setPurging(true);
|
||||
try {
|
||||
await adminApi.purgeUserData(purgeTarget.id);
|
||||
showSuccess(`Daten von ${purgeTarget.name} wurden gelöscht.`);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||
} catch {
|
||||
showError('Daten konnten nicht gelöscht werden.');
|
||||
} finally {
|
||||
setPurging(false);
|
||||
setPurgeTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||
}
|
||||
@@ -148,6 +177,7 @@ function UserOverviewTab() {
|
||||
Letzter Login
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell align="right">Aktionen</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -177,11 +207,35 @@ function UserOverviewTab() {
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{formatRelativeTime(user.last_login_at)}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Benutzerdaten löschen">
|
||||
<IconButton size="small" onClick={() => setPurgeTarget(user)}>
|
||||
<DeleteSweepIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<Dialog open={!!purgeTarget} onClose={() => !purging && setPurgeTarget(null)}>
|
||||
<DialogTitle>Benutzerdaten löschen?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Möchtest du alle persönlichen Daten von <strong>{purgeTarget?.name}</strong> löschen?
|
||||
Das Benutzerkonto bleibt erhalten, aber Profil, Benachrichtigungen und Buchungen werden
|
||||
unwiderruflich entfernt.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setPurgeTarget(null)} disabled={purging}>Abbrechen</Button>
|
||||
<Button onClick={handlePurge} color="error" variant="contained" disabled={purging}>
|
||||
{purging ? 'Wird gelöscht...' : 'Daten löschen'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
74
frontend/src/components/auth/FirstLoginDialog.tsx
Normal file
74
frontend/src/components/auth/FirstLoginDialog.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { membersService } from '../../services/members';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
userId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const FirstLoginDialog = ({ open, userId, onClose }: Props) => {
|
||||
const [standesbuchnummer, setStandesbuchnummer] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (standesbuchnummer.trim()) {
|
||||
await membersService.updateMember(userId, {
|
||||
fdisk_standesbuch_nr: standesbuchnummer.trim(),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// non-fatal, user can update later in profile
|
||||
} finally {
|
||||
setSaving(false);
|
||||
localStorage.setItem('firstLoginCompleted', 'true');
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
localStorage.setItem('firstLoginCompleted', 'true');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Willkommen!</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Du wurdest erfolgreich registriert. Bitte gib deine Standesbuchnummer ein, damit dein
|
||||
Profil zugeordnet werden kann. Dies kann auch später in deinen Profileinstellungen
|
||||
ergänzt werden.
|
||||
</Typography>
|
||||
<TextField
|
||||
label="Standesbuchnummer"
|
||||
value={standesbuchnummer}
|
||||
onChange={(e) => setStandesbuchnummer(e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="z.B. 12345"
|
||||
inputProps={{ maxLength: 32 }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleSkip} color="inherit">
|
||||
Überspringen
|
||||
</Button>
|
||||
<Button onClick={handleSave} variant="contained" disabled={saving}>
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -2,12 +2,15 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Box, CircularProgress, Typography, Alert, Button } from '@mui/material';
|
||||
import { FirstLoginDialog } from './FirstLoginDialog';
|
||||
|
||||
const LoginCallback: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { login } = useAuth();
|
||||
const { login, user } = useAuth();
|
||||
const [error, setError] = useState<string>('');
|
||||
const [showFirstLogin, setShowFirstLogin] = useState(false);
|
||||
const [redirectTo, setRedirectTo] = useState('/dashboard');
|
||||
const hasCalledLogin = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -29,7 +32,7 @@ const LoginCallback: React.FC = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
await login(code);
|
||||
const result = await login(code);
|
||||
// Navigate to the originally intended page, falling back to the dashboard.
|
||||
// Validate that the stored path is a safe internal path: must start with '/'
|
||||
// but must NOT start with '//' (protocol-relative redirect).
|
||||
@@ -39,7 +42,13 @@ const LoginCallback: React.FC = () => {
|
||||
? rawFrom
|
||||
: '/dashboard';
|
||||
sessionStorage.removeItem('auth_redirect_from');
|
||||
|
||||
if (result.isNewUser && !localStorage.getItem('firstLoginCompleted')) {
|
||||
setRedirectTo(from);
|
||||
setShowFirstLogin(true);
|
||||
} else {
|
||||
navigate(from, { replace: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Login callback error:', err);
|
||||
const is429 = err && typeof err === 'object' && 'status' in err && (err as any).status === 429;
|
||||
@@ -99,10 +108,20 @@ const LoginCallback: React.FC = () => {
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{showFirstLogin && user ? (
|
||||
<FirstLoginDialog
|
||||
open={showFirstLogin}
|
||||
userId={user.id}
|
||||
onClose={() => navigate(redirectTo, { replace: true })}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Anmeldung wird abgeschlossen...
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
44
frontend/src/components/common/PageBreadcrumbs.tsx
Normal file
44
frontend/src/components/common/PageBreadcrumbs.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Breadcrumbs, Typography, Link as MuiLink } from '@mui/material';
|
||||
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: BreadcrumbItem[];
|
||||
}
|
||||
|
||||
export const PageBreadcrumbs = ({ items }: Props) => {
|
||||
return (
|
||||
<Breadcrumbs
|
||||
separator={<NavigateNextIcon fontSize="small" />}
|
||||
sx={{ mb: 2, fontSize: '0.8125rem' }}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1;
|
||||
if (isLast || !item.href) {
|
||||
return (
|
||||
<Typography key={index} variant="body2" color="text.primary" fontWeight={500}>
|
||||
{item.label}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MuiLink
|
||||
key={index}
|
||||
component={Link}
|
||||
to={item.href}
|
||||
underline="hover"
|
||||
color="text.secondary"
|
||||
variant="body2"
|
||||
>
|
||||
{item.label}
|
||||
</MuiLink>
|
||||
);
|
||||
})}
|
||||
</Breadcrumbs>
|
||||
);
|
||||
};
|
||||
2
frontend/src/components/common/index.ts
Normal file
2
frontend/src/components/common/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { PageBreadcrumbs } from './PageBreadcrumbs';
|
||||
export type { BreadcrumbItem } from './PageBreadcrumbs';
|
||||
@@ -1,7 +1,5 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Chip,
|
||||
@@ -20,6 +18,7 @@ import { nextcloudApi } from '../../services/nextcloud';
|
||||
import type { NextcloudConversation } from '../../types/nextcloud.types';
|
||||
import { safeOpenUrl } from '../../utils/safeOpenUrl';
|
||||
import { useCountUp } from '../../hooks/useCountUp';
|
||||
import { WidgetCard } from '../templates/WidgetCard';
|
||||
|
||||
const POLL_INTERVAL = 2000;
|
||||
const POLL_TIMEOUT = 5 * 60 * 1000;
|
||||
@@ -183,22 +182,8 @@ const NextcloudTalkWidget: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
boxShadow: 3,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Forum color="primary" />
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>
|
||||
Nextcloud Talk
|
||||
</Typography>
|
||||
const headerAction = (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{connected && totalUnread > 0 && (
|
||||
<Chip
|
||||
label={`${animatedUnread} ungelesen`}
|
||||
@@ -215,7 +200,16 @@ const NextcloudTalkWidget: React.FC = () => {
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<WidgetCard
|
||||
title="Nextcloud Talk"
|
||||
icon={<Forum />}
|
||||
action={headerAction}
|
||||
noPadding
|
||||
>
|
||||
<Box sx={{ px: 2.5, pb: 2.5 }}>
|
||||
{isLoading && (
|
||||
<Box>
|
||||
{[1, 2, 3].map((n) => (
|
||||
@@ -276,8 +270,8 @@ const NextcloudTalkWidget: React.FC = () => {
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</WidgetCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { createContext, useCallback, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { AuthContextType, AuthState } from '../types/auth.types';
|
||||
import { AuthContextType, AuthState, LoginResult } from '../types/auth.types';
|
||||
import { authService } from '../services/auth';
|
||||
import { getToken, setToken, removeToken, getUser, setUser, removeUser, setRefreshToken, removeRefreshToken } from '../utils/storage';
|
||||
import { useNotification } from './NotificationContext';
|
||||
@@ -67,11 +67,11 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
initializeAuth();
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (code: string): Promise<void> => {
|
||||
const login = useCallback(async (code: string): Promise<LoginResult> => {
|
||||
try {
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
const { token, refreshToken, user } = await authService.handleCallback(code);
|
||||
const { token, refreshToken, user, isNewUser } = await authService.handleCallback(code);
|
||||
|
||||
// Save to localStorage
|
||||
setToken(token);
|
||||
@@ -88,6 +88,8 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
|
||||
// Show success notification
|
||||
notification.showSuccess('Anmeldung erfolgreich');
|
||||
|
||||
return { isNewUser };
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
setState({
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { PageBreadcrumbs } from '../components/common';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { settingsApi } from '../services/settings';
|
||||
@@ -293,6 +294,10 @@ function AdminSettings() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
<PageBreadcrumbs items={[
|
||||
{ label: 'Admin', href: '/admin' },
|
||||
{ label: 'Einstellungen' },
|
||||
]} />
|
||||
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
|
||||
Admin-Einstellungen
|
||||
</Typography>
|
||||
|
||||
@@ -779,6 +779,10 @@ function AusruestungDetailPage() {
|
||||
<DetailLayout
|
||||
title={equipment.bezeichnung}
|
||||
backTo="/ausruestung"
|
||||
breadcrumbs={[
|
||||
{ label: 'Ausrüstung', href: '/ausruestung' },
|
||||
{ label: equipment.bezeichnung },
|
||||
]}
|
||||
tabs={tabs}
|
||||
actions={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { PageBreadcrumbs } from '../components/common';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
||||
@@ -285,6 +286,10 @@ export default function AusruestungsanfrageArtikelDetail() {
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<PageBreadcrumbs items={[
|
||||
{ label: 'Ausrüstungsanfragen', href: '/ausruestungsanfrage' },
|
||||
{ label: isCreate ? 'Neuer Katalogartikel' : (artikel?.bezeichnung ?? 'Artikel') },
|
||||
]} />
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<IconButton onClick={() => navigate('/ausruestungsanfrage?tab=2')}>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { PageBreadcrumbs } from '../components/common';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
@@ -193,6 +194,10 @@ export default function AusruestungsanfrageDetail() {
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<PageBreadcrumbs items={[
|
||||
{ label: 'Ausrüstungsanfragen', href: '/ausruestungsanfrage' },
|
||||
{ label: anfrage ? `Anfrage ${formatOrderId(anfrage)}` : 'Anfrage' },
|
||||
]} />
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<IconButton onClick={() => navigate('/ausruestungsanfrage')}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ArrowBack, Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-mate
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { PageBreadcrumbs } from '../components/common';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
||||
@@ -168,6 +169,10 @@ export default function AusruestungsanfrageNeu() {
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<PageBreadcrumbs items={[
|
||||
{ label: 'Ausrüstungsanfragen', href: '/ausruestungsanfrage' },
|
||||
{ label: 'Neue Bestellung' },
|
||||
]} />
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<IconButton onClick={() => navigate('/ausruestungsanfrage')}>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { PageBreadcrumbs } from '../components/common';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
||||
@@ -236,6 +237,11 @@ export default function AusruestungsanfrageZuBestellung() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ maxWidth: 960, mx: 'auto', px: 2, py: 3 }}>
|
||||
<PageBreadcrumbs items={[
|
||||
{ label: 'Ausrüstungsanfragen', href: '/ausruestungsanfrage' },
|
||||
{ label: anfrage.bezeichnung || `Anfrage ${formatOrderId(anfrage)}`, href: `/ausruestungsanfrage/${requestId}` },
|
||||
{ label: 'Bestellen' },
|
||||
]} />
|
||||
|
||||
{/* ── Header ── */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 3 }}>
|
||||
|
||||
@@ -673,6 +673,10 @@ export default function BestellungDetail() {
|
||||
<PageHeader
|
||||
title={bestellung.bezeichnung}
|
||||
backTo="/bestellungen"
|
||||
breadcrumbs={[
|
||||
{ label: 'Bestellungen', href: '/bestellungen' },
|
||||
{ label: bestellung.bezeichnung },
|
||||
]}
|
||||
actions={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{canExport && !editMode && (
|
||||
|
||||
@@ -23,6 +23,7 @@ import { ArrowBack } from '@mui/icons-material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { PageBreadcrumbs } from '../components/common';
|
||||
import { buchhaltungApi } from '../services/buchhaltung';
|
||||
import { TRANSAKTION_TYP_LABELS } from '../types/buchhaltung.types';
|
||||
import type { BankkontoStatementRow } from '../types/buchhaltung.types';
|
||||
@@ -61,6 +62,10 @@ export default function BuchhaltungBankkontoDetail() {
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<PageBreadcrumbs items={[
|
||||
{ label: 'Buchhaltung', href: '/buchhaltung' },
|
||||
{ label: data?.bankkonto?.bezeichnung || 'Bankkonto' },
|
||||
]} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<Tooltip title="Zurueck">
|
||||
<IconButton onClick={() => navigate('/buchhaltung?tab=2')}>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import { ArrowBack, KeyboardArrowDown, KeyboardArrowUp } from '@mui/icons-material';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { PageBreadcrumbs } from '../components/common';
|
||||
import { buchhaltungApi } from '../services/buchhaltung';
|
||||
import { AUSGABEN_TYP_LABELS } from '../types/buchhaltung.types';
|
||||
import type { AusgabenTyp, BuchhaltungAudit } from '../types/buchhaltung.types';
|
||||
@@ -132,6 +133,10 @@ export default function BuchhaltungKontoDetail() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<PageBreadcrumbs items={[
|
||||
{ label: 'Buchhaltung', href: '/buchhaltung' },
|
||||
{ label: `${konto.kontonummer} — ${konto.bezeichnung}` },
|
||||
]} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<Button startIcon={<ArrowBack />} onClick={() => navigate('/buchhaltung')}>
|
||||
Zurück
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import { ArrowBack, Delete, Edit, Save, Cancel } from '@mui/icons-material';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { PageBreadcrumbs } from '../components/common';
|
||||
import { buchhaltungApi } from '../services/buchhaltung';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import type { KontoFormData, BudgetTyp } from '../types/buchhaltung.types';
|
||||
@@ -172,6 +173,11 @@ export default function BuchhaltungKontoManage() {
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<PageBreadcrumbs items={[
|
||||
{ label: 'Buchhaltung', href: '/buchhaltung' },
|
||||
{ label: `${konto.kontonummer} — ${konto.bezeichnung}`, href: `/buchhaltung/konto/${id}` },
|
||||
{ label: 'Verwalten' },
|
||||
]} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<Button startIcon={<ArrowBack />} onClick={() => navigate('/buchhaltung?tab=2')}>
|
||||
Zurück
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ArrowBack, CheckCircle, Cancel, RemoveCircle } from '@mui/icons-materia
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { PageBreadcrumbs } from '../components/common';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { checklistenApi } from '../services/checklisten';
|
||||
@@ -243,6 +244,10 @@ export default function ChecklistAusfuehrung() {
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<PageBreadcrumbs items={[
|
||||
{ label: 'Checklisten', href: '/checklisten' },
|
||||
{ label: execution.vorlage_name ?? 'Checkliste' },
|
||||
]} />
|
||||
<Button startIcon={<ArrowBack />} onClick={() => navigate('/checklisten')} sx={{ mb: 2 }} size="small">
|
||||
Checklisten
|
||||
</Button>
|
||||
|
||||
@@ -284,6 +284,10 @@ function EinsatzDetail() {
|
||||
title={`Einsatz ${einsatz.einsatz_nr}`}
|
||||
subtitle={address || undefined}
|
||||
backTo="/einsaetze"
|
||||
breadcrumbs={[
|
||||
{ label: 'Einsätze', href: '/einsaetze' },
|
||||
{ label: `Einsatz ${einsatz.einsatz_nr}` },
|
||||
]}
|
||||
actions={
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Chip
|
||||
|
||||
@@ -1006,6 +1006,10 @@ function FahrzeugDetail() {
|
||||
<DetailLayout
|
||||
title={titleText}
|
||||
backTo="/fahrzeuge"
|
||||
breadcrumbs={[
|
||||
{ label: 'Fahrzeuge', href: '/fahrzeuge' },
|
||||
{ label: titleText },
|
||||
]}
|
||||
tabs={tabs}
|
||||
actions={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { PageBreadcrumbs } from '../components/common';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
@@ -143,6 +144,10 @@ export default function HaushaltsplanDetail() {
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<PageBreadcrumbs items={[
|
||||
{ label: 'Haushaltsplan', href: '/haushaltsplan' },
|
||||
{ label: planung.bezeichnung },
|
||||
]} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<IconButton onClick={() => navigate('/haushaltsplan')}><ArrowBack /></IconButton>
|
||||
<Typography variant="h4" sx={{ flexGrow: 1 }}>{planung.bezeichnung}</Typography>
|
||||
|
||||
@@ -264,6 +264,10 @@ export default function IssueDetail() {
|
||||
<PageHeader
|
||||
title={`${formatIssueId(issue)} — ${issue.titel}`}
|
||||
backTo="/issues"
|
||||
breadcrumbs={[
|
||||
{ label: 'Issues', href: '/issues' },
|
||||
{ label: `${formatIssueId(issue)} — ${issue.titel}` },
|
||||
]}
|
||||
actions={
|
||||
<Chip
|
||||
label={getStatusLabel(statuses, issue.status)}
|
||||
|
||||
@@ -419,6 +419,10 @@ function MitgliedDetail() {
|
||||
<PageHeader
|
||||
title={displayName}
|
||||
backTo="/mitglieder"
|
||||
breadcrumbs={[
|
||||
{ label: 'Mitglieder', href: '/mitglieder' },
|
||||
{ label: displayName },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Header card */}
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { PageBreadcrumbs } from '../components/common';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { trainingApi } from '../services/training';
|
||||
import type { TeilnahmeStatus, UebungTyp, Teilnahme } from '../types/training.types';
|
||||
@@ -332,6 +333,10 @@ export default function UebungDetail() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ maxWidth: 720, mx: 'auto' }}>
|
||||
<PageBreadcrumbs items={[
|
||||
{ label: 'Kalender', href: '/kalender' },
|
||||
{ label: event.titel || 'Übung' },
|
||||
]} />
|
||||
{/* Back button */}
|
||||
<Button
|
||||
startIcon={<BackIcon />}
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
Category as CategoryIcon,
|
||||
} from '@mui/icons-material';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { PageBreadcrumbs } from '../components/common';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { eventsApi } from '../services/events';
|
||||
@@ -339,6 +340,10 @@ export default function VeranstaltungKategorien() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg" sx={{ py: 3 }}>
|
||||
<PageBreadcrumbs items={[
|
||||
{ label: 'Veranstaltungen', href: '/veranstaltungen' },
|
||||
{ label: 'Kategorien' },
|
||||
]} />
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
|
||||
@@ -31,4 +31,5 @@ export const adminApi = {
|
||||
fdiskSyncLogs: () => api.get<ApiResponse<FdiskSyncLogsResponse>>('/api/admin/fdisk-sync/logs').then(r => r.data.data),
|
||||
fdiskSyncTrigger: (force = false) => api.post<ApiResponse<{ started: boolean }>>('/api/admin/fdisk-sync/trigger', { force }).then(r => r.data.data),
|
||||
purgeFdiskData: (userId: string) => api.delete<ApiResponse<{ profileFieldsCleared: number; ausbildungen: number; befoerderungen: number; untersuchungen: number; fahrgenehmigungen: number }>>(`/api/admin/users/${userId}/fdisk-data`).then(r => r.data.data),
|
||||
purgeUserData: (userId: string) => api.post<ApiResponse<Record<string, number>>>(`/api/admin/users/${userId}/purge-data`).then(r => r.data.data),
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface AuthCallbackResponse {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
user: User;
|
||||
isNewUser?: boolean;
|
||||
}
|
||||
|
||||
// The backend returns camelCase field names; map them to the snake_case User type.
|
||||
@@ -45,7 +46,7 @@ export const authService = {
|
||||
const response = await api.post<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: { accessToken: string; refreshToken: string; user: Record<string, unknown> };
|
||||
data: { accessToken: string; refreshToken: string; isNewUser?: boolean; user: Record<string, unknown> };
|
||||
}>('/api/auth/callback', {
|
||||
code,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
@@ -54,6 +55,7 @@ export const authService = {
|
||||
token: response.data.data.accessToken,
|
||||
refreshToken: response.data.data.refreshToken,
|
||||
user: mapBackendUser(response.data.data.user),
|
||||
isNewUser: response.data.data.isNewUser,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -21,8 +21,12 @@ export interface AuthState {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
isNewUser?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthContextType extends AuthState {
|
||||
login: (code: string) => Promise<void>;
|
||||
login: (code: string) => Promise<LoginResult>;
|
||||
logout: () => void;
|
||||
refreshAuth: () => Promise<void>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user