Fix period editing and harden app with Next.js security upgrade
All checks were successful
CI / Build (push) Successful in 1m10s
CI / Deploy (push) Successful in 1m11s

This commit is contained in:
Jan Hanewinkel
2026-04-20 00:02:46 +02:00
parent 5a8b0871a0
commit f947908f0e
14 changed files with 2595 additions and 51 deletions

View File

@@ -110,6 +110,12 @@ type PeriodFormState = {
copyBudgetsFromPeriodId: string;
};
type PeriodEditFormState = {
name: string;
startsAt: string;
endsAt: string;
};
type DashboardMessage = {
type: "success" | "error";
text: string;
@@ -191,6 +197,22 @@ function getSuggestedPeriodDraft(currentPeriod: DashboardAccountingPeriod | unde
};
}
function getPeriodEditDraft(period: DashboardAccountingPeriod | null | undefined): PeriodEditFormState {
if (!period) {
return {
name: "",
startsAt: "",
endsAt: ""
};
}
return {
name: period.name,
startsAt: toDateInputValue(period.startsAt),
endsAt: toDateInputValue(period.endsAt)
};
}
function generatePassword(length = 14) {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@$%";
const cryptoSource = globalThis.crypto;
@@ -297,6 +319,7 @@ export function DashboardShell({
const [userDrafts, setUserDrafts] = useState<Record<string, ManagedUserDraft>>({});
const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2));
const [periodForm, setPeriodForm] = useState<PeriodFormState>(getSuggestedPeriodDraft(currentPeriod));
const [periodEditForm, setPeriodEditForm] = useState<PeriodEditFormState>(getPeriodEditDraft(currentPeriod));
useEffect(() => {
if (visibleGroups.length === 0) {
setSelectedMobileGroupId("");
@@ -314,6 +337,11 @@ export function DashboardShell({
setPeriodForm(getSuggestedPeriodDraft(currentPeriod));
}, [currentPeriod, currentPeriodId]);
useEffect(() => {
const selectedPeriod = accountingPeriods.find((period) => period.id === selectedCurrentPeriodId) ?? currentPeriod ?? null;
setPeriodEditForm(getPeriodEditDraft(selectedPeriod));
}, [accountingPeriods, currentPeriod, selectedCurrentPeriodId]);
useEffect(() => {
if (!desktopSections.some((section) => section.value === desktopSection)) {
setDesktopSection("overview");
@@ -468,6 +496,11 @@ export function DashboardShell({
) ?? 0;
const selectedPeriodForManagement =
accountingPeriods.find((period) => period.id === selectedCurrentPeriodId) ?? currentPeriod ?? null;
const periodEditDirty =
selectedPeriodForManagement !== null &&
(periodEditForm.name.trim() !== selectedPeriodForManagement.name ||
periodEditForm.startsAt !== toDateInputValue(selectedPeriodForManagement.startsAt) ||
periodEditForm.endsAt !== toDateInputValue(selectedPeriodForManagement.endsAt));
function getManagedUserDraft(user: DashboardManagedUser): ManagedUserDraft {
return userDrafts[user.id] ?? {
@@ -810,6 +843,27 @@ export function DashboardShell({
}, "Neuer Abrechnungszeitraum wurde angelegt.");
}
async function handleSavePeriod(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedPeriodForManagement) {
setMessage({ type: "error", text: "Bitte zuerst einen Zeitraum auswählen." });
return;
}
await runAction(async () => {
await parseResponse(
await fetch(`/api/periods/${selectedPeriodForManagement.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(periodEditForm)
})
);
}, `Zeitraum ${periodEditForm.name.trim() || selectedPeriodForManagement.name} wurde aktualisiert.`);
}
async function handleDeletePeriod(periodId: string, periodName: string) {
await runAction(async () => {
await parseResponse(
@@ -1222,6 +1276,55 @@ export function DashboardShell({
: "Leere, nicht aktive Zeiträume lassen sich hier wieder entfernen."}
</Typography>
<Box component="form" onSubmit={handleSavePeriod} sx={nestedPanelSx}>
<Stack spacing={1.4}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Ausgewählten Zeitraum bearbeiten
</Typography>
<TextField
label="Zeitraum-Name"
value={periodEditForm.name}
onChange={(event) => setPeriodEditForm((current) => ({ ...current, name: event.target.value }))}
required
fullWidth
disabled={!selectedPeriodForManagement}
/>
<Stack direction={{ xs: "column", sm: "row" }} gap={1.2}>
<TextField
label="Von"
type="date"
value={periodEditForm.startsAt}
onChange={(event) => setPeriodEditForm((current) => ({ ...current, startsAt: event.target.value }))}
InputLabelProps={{ shrink: true }}
required
fullWidth
disabled={!selectedPeriodForManagement}
/>
<TextField
label="Bis"
type="date"
value={periodEditForm.endsAt}
onChange={(event) => setPeriodEditForm((current) => ({ ...current, endsAt: event.target.value }))}
InputLabelProps={{ shrink: true }}
required
fullWidth
disabled={!selectedPeriodForManagement}
/>
</Stack>
<Typography variant="body2" color="text.secondary">
{"Abo-Berechnungen nutzen danach direkt den neuen Zeitraum."}
</Typography>
<Button
type="submit"
variant="outlined"
startIcon={<EditRoundedIcon />}
disabled={busy || !selectedPeriodForManagement || !periodEditDirty}
>
Zeitraum speichern
</Button>
</Stack>
</Box>
<Box component="form" onSubmit={handleCreatePeriod} sx={nestedPanelSx}>
<Stack spacing={1.4}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
@@ -2412,5 +2515,3 @@ export function DashboardShell({
</Box>
);
}