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:
@@ -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');
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
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,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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user