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:
Matthias Hochmeister
2026-04-13 16:15:28 +02:00
parent a0b3c0ec5c
commit b477e5dbe0
32 changed files with 485 additions and 49 deletions

View File

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

View 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>
);
};

View File

@@ -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');
navigate(from, { replace: true });
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,
}}
>
<CircularProgress size={60} />
<Typography variant="h6" color="text.secondary">
Anmeldung wird abgeschlossen...
</Typography>
{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>
);
};

View 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>
);
};

View File

@@ -0,0 +1,2 @@
export { PageBreadcrumbs } from './PageBreadcrumbs';
export type { BreadcrumbItem } from './PageBreadcrumbs';

View File

@@ -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,39 +182,34 @@ 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>
{connected && totalUnread > 0 && (
<Chip
label={`${animatedUnread} ungelesen`}
size="small"
color="primary"
variant="outlined"
/>
)}
{connected && (
<Tooltip title="Verbindung trennen">
<IconButton size="small" onClick={handleDisconnect}>
<LinkOff fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
const headerAction = (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{connected && totalUnread > 0 && (
<Chip
label={`${animatedUnread} ungelesen`}
size="small"
color="primary"
variant="outlined"
/>
)}
{connected && (
<Tooltip title="Verbindung trennen">
<IconButton size="small" onClick={handleDisconnect}>
<LinkOff fontSize="small" />
</IconButton>
</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>
);
};