Merge family absence flows while preserving security hardening

This commit is contained in:
t.indorf
2026-10-10 07:08:41 +02:00
30 changed files with 2822 additions and 644 deletions
@@ -1,6 +1,11 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import {
useMemo,
useState,
useTransition,
type ChangeEventHandler,
} from "react";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
@@ -17,12 +22,15 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { updateFamilyAction } from "./actions";
import { InvitationStatus } from "./invitation-status";
import type { FamilyInvitationView } from "@/lib/family-invitation-view";
type EditableParent = {
id: string;
firstName: string;
lastName: string;
email: string;
invitation?: FamilyInvitationView;
};
type EditableChild = {
@@ -47,6 +55,7 @@ type EditableFamily = {
};
export function EditFamilyDialog({ family }: { family: EditableFamily }) {
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const initialParents = useMemo(
() =>
@@ -154,13 +163,22 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
return;
}
toast.success("Familie aktualisiert. Geänderte E-Mail-Adressen müssen über einen neuen Einladungslink bestätigt werden.");
if (result.warning) toast.warning(result.warning);
else toast.success("Familie aktualisiert. Geänderte E-Mail-Adressen müssen über einen neuen Einladungslink bestätigt werden.");
setOpen(false);
setRemovedChildIds([]);
});
}
return (
<Dialog onOpenChange={(open) => !open && resetState()}>
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (isPending) return;
resetState();
setOpen(nextOpen);
}}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1">
<Pencil className="h-3.5 w-3.5" />
@@ -168,7 +186,12 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<form action={handleSubmit}>
<form
onSubmit={(event) => {
event.preventDefault();
handleSubmit(new FormData(event.currentTarget));
}}
>
<DialogHeader>
<DialogTitle>Familie bearbeiten</DialogTitle>
<DialogDescription>
@@ -176,7 +199,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
</DialogDescription>
</DialogHeader>
<div className="grid gap-6 py-4">
<fieldset disabled={isPending} className="grid gap-6 py-4">
<div className="grid gap-2">
<Label htmlFor={`familyName-${family.id}`}>Familienname</Label>
<Input
@@ -251,8 +274,38 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
label="E-Mail-Adresse"
type="email"
defaultValue={parent.email}
onChange={(event) => {
const email = event.target.value;
setParents((current) =>
current.map((item) =>
item.id === parent.id ? { ...item, email } : item,
),
);
}}
/>
</div>
{parent.kind === "existing" && parent.invitation && (
<InvitationStatus
userId={parent.id}
name={`${parent.firstName} ${parent.lastName}`}
invitation={parent.invitation}
blocked={
parent.email !==
family.parents.find((item) => item.id === parent.id)
?.email
}
/>
)}
{parent.kind === "existing" &&
parent.invitation?.status !== "active" &&
parent.email !==
family.parents.find((item) => item.id === parent.id)
?.email && (
<p className="mt-2 text-xs text-[var(--ink-soft)]">
Beim Speichern wird eine neue Einladung an die geänderte
Adresse versendet.
</p>
)}
</div>
))}
</fieldset>
@@ -319,7 +372,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
))
)}
</fieldset>
</div>
</fieldset>
<DialogFooter>
<Button type="submit" disabled={isPending}>
@@ -338,12 +391,14 @@ function Field({
label,
type = "text",
defaultValue,
onChange,
}: {
id: string;
name: string;
label: string;
type?: string;
defaultValue: string;
onChange?: ChangeEventHandler<HTMLInputElement>;
}) {
return (
<div className="grid gap-2">
@@ -353,6 +408,7 @@ function Field({
name={name}
type={type}
defaultValue={defaultValue}
onChange={onChange}
required
/>
</div>