Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
661776e609 | ||
|
|
061024d901 |
@@ -2,7 +2,7 @@
|
||||
|
||||
Stand: 9. Oktober 2026 · geprüfter Code: `4702bf5abb0037ab9e0913dbef01ffbe21bbb96d` (`origin/main` zum Prüfzeitpunkt).
|
||||
|
||||
Fortschreibung: F01 (Familieneinladungen), F02 (Abwesenheitsverwaltung) und die Entfernung des künstlichen Gruppenfilters aus F12 wurden anschließend auf `codex/family-absence-flows` bearbeitet. Den neuen Ablauf und die Deployment-Schritte beschreibt [Familien und Abwesenheiten](FAMILIEN_UND_ABWESENHEITEN.md). Die folgenden Befunde dokumentieren weiterhin den ursprünglichen Prüfstand.
|
||||
Fortschreibung: F01 (Familieneinladungen), F02 (Abwesenheitsverwaltung) und die Entfernung des künstlichen Gruppenfilters aus F12 wurden anschließend auf `codex/family-absence-flows` bearbeitet. Den neuen Ablauf und die Deployment-Schritte beschreibt [Familien und Abwesenheiten](FAMILIEN_UND_ABWESENHEITEN.md). F03, F04 und F05 (Verfügbarkeit bei manueller Zuweisung sowie Erstveröffentlichung) sind inzwischen ebenfalls umgesetzt; den aktuellen Stand und die Prüfungen dokumentiert die Ergänzung vom 12.10.2026 in [Funktionsübersicht](FUNKTIONSUEBERSICHT.md). F06 (Agenda-Details und Mitbringen), F09 (Arbeitszeitkontext und Ein-Tages-Anträge), F10 (personenbezogene Ämter-Schaltflächen) und F11 (Passwort-Wiederherstellung/-Änderung) sind ebenfalls umgesetzt; F08 enthält nun kontoabhängiges Ausblenden des Einrichtungshinweises. Die weiteren Teile von F08 und F11 bleiben durch diese Ergänzung unberührt. Die folgenden Befunde dokumentieren weiterhin den ursprünglichen Prüfstand.
|
||||
|
||||
## Umfang und Methode
|
||||
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
# Kita-Planer – Funktionsübersicht und aktueller Stand
|
||||
|
||||
## Ergänzung vom 12.10.2026: Kontozugang und weniger wiederholte Eingaben
|
||||
|
||||
- Die Ämter-Schaltflächen nennen die betroffene Person („Ämter: Vorname“) und tragen einen vollständigen zugänglichen Namen. Der Einrichtungshinweis wird je Konto in diesem Browser ausgeblendet. Das alte gemeinsame Ausblenden gilt nicht mehr für alle Konten; fehlender Browser-Speicher verhindert die Nutzung nicht.
|
||||
- Unter `/passwort-vergessen` können aktivierte Konten einen Wiederherstellungslink anfordern. Die öffentliche Antwort bleibt für bestehende, unbekannte, deaktivierte und begrenzte Anfragen gleich. Kontosuche und Mailversand laufen nach der Antwort über Next.js `after`; ein Fehler verrät öffentlich keine Kontodaten.
|
||||
- Reset-Links sind 30 Minuten gültig, einmal nutzbar und an die aktuelle Sitzungsfassung gebunden. Die vorhandene Verifikationstabelle speichert ausschließlich den Tokenhash und einen eigenen Reset-Zweck. Einladungslinks können kein Passwort zurücksetzen. Neue Reset-Anfragen ersetzen ältere Links; zwischenzeitliche E-Mail-/Passwortänderungen, administrative Kontosperren oder Löschungen verhindern die Verwendung alter Links. Fehlgeschlagener Versand entfernt ausschließlich den eigenen neu erzeugten Link.
|
||||
- Im Profil lässt sich das Passwort mit dem aktuellen Passwort und übereinstimmender Bestätigung ändern. Sowohl Wiederherstellung als auch eigene Änderung erhöhen die Sitzungsfassung und beenden alte Anmeldungen; eine neue Anmeldung ist erforderlich. Persönliche Alarmlinks, die an diese frühere Sitzungsfassung gebunden sind, werden ebenfalls ungültig. Kontoaktivierung, Kita, Haushalt, Rollen und Einwilligungen bleiben erhalten. Es gelten weiterhin mindestens acht Zeichen und die bcrypt-Grenze von 72 UTF-8-Bytes.
|
||||
- Anfragebegrenzungen: drei Reset-Anfragen je E-Mail/Stunde, fünf Reset-Abschlussversuche je Link/15 Minuten und fünf eigene Passwortänderungsversuche je Konto/15 Minuten; zusätzlich die bestehenden gemeinsamen und ggf. Proxy-IP-Limits. Bestehende Mailkonfiguration und App-Adresse werden verwendet. Keine neue Umgebungsvariable oder Datenbankmigration erforderlich.
|
||||
- Die Kalender-Agenda öffnet den bereits vorhandenen Tagesdetaildialog mit Beschreibung, Rückmeldung, Mitbringliste und berechtigten Verwaltungsaktionen. Ansicht und Suche bleiben beim Schließen erhalten. Die Agenda passt sich auf schmalen Bildschirmen an.
|
||||
- Neue Arbeitszeitformulare übernehmen die betrachtete Woche bzw. den ausgewählten Tag. Tage in der Zukunft sind nicht erfassbar. „Speichern und weiteren Block erfassen“ hält den tatsächlich eingegebenen Tag fest, leert die Uhrzeiten, setzt die Pause auf null und fokussiert den Beginn. Bestehende Zeiteinträge werden weiterhin einzeln bearbeitet; serverseitige Zeit-, Überschneidungs- und Freigaberegeln bleiben maßgeblich.
|
||||
- Freizeitausgleich und halber Urlaubstag benötigen ein Datum. Das Ende wird aus diesem Datum übernommen; mehrtägiger Urlaub und Krankheit behalten Von/Bis. Die Berechnungs- und Genehmigungsregeln ändern sich nicht.
|
||||
|
||||
Prüfbefehl: `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_account_test npm run test:account` nach Anwendung der vorhandenen Migrationen auf dieser separaten lokalen Testdatenbank. Die 20 neuen PostgreSQL-Tests prüfen den tatsächlichen Passwortdienst, Server Actions, bcrypt und JWT-Entwertung mit ersetztem Mailtransport. Dazu 139 bestehende Sicherheits-, Rollen-, Kalender-, Familien- und Personaltests erfolgreich (159 insgesamt, keine übersprungenen Tests).
|
||||
|
||||
Browser-Abnahme mit synthetischen Konten auf Desktop und bei 390 Pixeln: neutrale Reset-Bestätigung und tatsächliche nachgelagerte Vorbereitung, gültige/ungültige Linkseiten, Passwortformular im Profil, getrennte Einrichtungshinweise bei Konto-Wechsel, eindeutige Ämter, Agenda-Details samt gespeichertem Mitbringbeitrag und erhaltenem Suchfilter. Arbeitszeit in vergangener Woche und am ausgewählten Tag geprüft; zwei Blöcke mit 60 und 45 Minuten am selben eingegebenen Tag gespeichert. Halber Urlaubstag mit identischem Start/Ende gespeichert; Freizeitausgleich zeigt ebenfalls nur ein Datum. Passwortänderungen selbst wurden in den Integrationstests ausgeführt. Es wurden keine echten Mails verschickt oder produktiven Daten verändert. Produktionsbuild und TypeScript erfolgreich; ESLint ohne Fehler mit fünf bestehenden Warnungen.
|
||||
|
||||
|
||||
## Ergänzung vom 12.10.2026: Manuelle Planung und Zugriffsrechte
|
||||
|
||||
- Die manuelle Notdienst-Zuweisung gruppiert verfügbare Haushalte zuerst. Familien ohne Verfügbarkeit für den gewählten Tag bleiben als abgestimmter Sonderfall auswählbar und werden nach einer solchen Einteilung ausdrücklich gekennzeichnet. Geschwister zählen als ein Haushalt; Einteilungszahlen gelten ebenfalls je Haushalt. Die Anzeige verwendet dieselben gültigen Monatstage wie die Planung. Bestehende Alarm- und Schließtagssperren bleiben bestehen.
|
||||
- Kita-Admins verwalten Zugriffsrollen unter `/dashboard/zugriffsrechte` sowie direkt bei Personen in Familien- und Teamverwaltung. Die Übersicht enthält auch Konten ohne eigenen Haushalt, eine Suche und die letzten 15 protokollierten Rollenänderungen der eigenen Kita.
|
||||
- Haushaltskonten können Eltern, Notdienst-Koordination oder Kita-Admin sein. Ohne Haushalt stehen Koordination und Kita-Admin zur Verfügung. Konten mit Personalprofil können Fachkraft oder Kita-Admin sein; Arbeitszeiten, Anträge und Haushalt bleiben erhalten. Ein Personalprofil wird durch eine Rollenänderung nicht neu angelegt. Systemadmins werden hier nicht verwaltet. Ämter wie „Vorstand“ verleihen keine Zugriffsrechte.
|
||||
- Rollenänderungen prüfen Akteur und Zielkonto erneut innerhalb einer serialisierbaren Transaktion. Eine zwischenzeitlich geänderte Rolle wird nicht überschrieben. Das bestehende Login liest die Rolle bei jeder Anfrage neu; eigene Herabstufung führt zurück zur Übersicht. Offene Aktivierungslinks benötigen weiterhin die Aktivierung, bevor Zugang entsteht.
|
||||
- Mindestens ein aktiver Kita-Admin mit verifiziertem Konto, Passwort und Datenschutzzustimmung bleibt erhalten. Herabstufung, eigene Kontolöschung, Support-Löschung/-Sperre und eine E-Mail-Änderung mit erneuter Aktivierung berücksichtigen denselben Schutz. Offene Einladungen, deaktivierte/gesperrte Konten oder fehlende Zustimmung zählen nicht als Ersatz. Gleichzeitige Änderungen werden abgefangen.
|
||||
- Eltern mit Admin-/Koordinationsrolle erhalten weiterhin Haushaltsalarme, können über ihre persönlichen Links antworten und erhalten Mails nach manueller Umteilung. Fachkräfte mit Admin-Rolle können ihr verknüpftes Personalprofil weiter nutzen; persönliche Benachrichtigungseinstellungen und offene Fachkraftaktivierungen bleiben funktionsfähig.
|
||||
- Kein Schema-Upgrade erforderlich.
|
||||
|
||||
Prüfbefehl: `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_roles_test npm run test:roles` nach Anwendung der vorhandenen Migrationen auf dieser separaten lokalen Testdatenbank. Die 27 Rollen-/PostgreSQL-Tests und sechs tatsächlichen Komponenten-Rendertests verwenden synthetische Konten und ersetzte Mailtransporte.
|
||||
|
||||
Abnahme: 33 neue Tests und 193 bestehende Sicherheits-, Planungs-, Kalender-, Versand-, Familien-, Personal- und Alarmtests erfolgreich (226 insgesamt, keine übersprungenen Tests). Browserprüfung auf Desktop und bei 390 Pixeln: verfügbare und bewusst abweichende Einteilung, Rollenänderung aus Familien-/Teamverwaltung, Suche, Änderungshistorie, Schutz für einzigen Admin und eigene Herabstufung mit unmittelbar verweigertem Verwaltungszugriff. Produktionsbuild und TypeScript erfolgreich; ESLint ohne Fehler mit sieben bestehenden Warnungen.
|
||||
|
||||
|
||||
## Ergänzung vom 12.10.2026: Notdienst-Benachrichtigungen
|
||||
|
||||
- Automatische Erinnerungen erfassen auch unvollständige Verfügbarkeiten. Das Soll wird aus allen aktiven Kindern und die Istzahl aus unterschiedlichen gültigen Tagen berechnet. Admin-/Koordinationskonten mit eigenem Haushalt gehören zur Eltern-Empfängergruppe.
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
"test:calendar:integration": "node --test tests/calendar-actions.integration.test.cjs",
|
||||
"test:notdienst:notifications": "node --test tests/notdienst-notifications.integration.test.cjs",
|
||||
"test:mail": "node --test tests/mail-transport.test.cjs",
|
||||
"test:account": "node --test tests/password-management.integration.test.cjs",
|
||||
"test:roles": "node --test tests/kita-roles.integration.test.cjs tests/notdienst-assignment-view.test.cjs",
|
||||
"test:runtime": "node --test tests/runtime-supervisor.test.cjs",
|
||||
"test:family-absence": "tsx --test tests/absence-policy.test.ts",
|
||||
"test:family-absence:integration": "tsx --experimental-test-module-mocks --test tests/family-absence-flows.integration.test.ts"
|
||||
|
||||
+51
-14
@@ -7,6 +7,12 @@ import { AuditTargetType, UserRole } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import {
|
||||
assertKitaAdminRemains,
|
||||
lockKitaAccounts,
|
||||
} from "@/lib/kita-admin-guard";
|
||||
import { serializableTransaction } from "@/lib/transaction";
|
||||
import { UserFacingError } from "@/lib/action-error";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const deleteKitaSchema = z.object({
|
||||
@@ -28,7 +34,8 @@ type ActionResult = {
|
||||
|
||||
async function requireSuperadminActor() {
|
||||
const session = await requireRole([UserRole.SUPERADMIN]);
|
||||
if (Date.now() - session.user.authenticatedAt > 15 * 60_000) redirect("/login?reauth=1&callbackUrl=/admin");
|
||||
if (Date.now() - session.user.authenticatedAt > 15 * 60_000)
|
||||
redirect("/login?reauth=1&callbackUrl=/admin");
|
||||
return {
|
||||
id: session.user.id,
|
||||
email: session.user.email ?? session.user.name ?? "Unbekannter Superadmin",
|
||||
@@ -96,7 +103,10 @@ export async function deleteKita(
|
||||
const counts = await getKitaDeleteCounts(kita.id);
|
||||
|
||||
const attachmentIds = await prisma.$transaction(async (tx) => {
|
||||
const attachments = await tx.attachment.findMany({ where: { announcement: { kitaId: kita.id } }, select: { id: true } });
|
||||
const attachments = await tx.attachment.findMany({
|
||||
where: { announcement: { kitaId: kita.id } },
|
||||
select: { id: true },
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
actorUserId: actor.id,
|
||||
@@ -111,7 +121,7 @@ export async function deleteKita(
|
||||
});
|
||||
|
||||
await tx.kita.delete({ where: { id: kita.id } });
|
||||
return attachments.map(item => item.id);
|
||||
return attachments.map((item) => item.id);
|
||||
});
|
||||
|
||||
await removeAttachmentFiles(attachmentIds);
|
||||
@@ -156,13 +166,19 @@ async function findMutableTenantUser(userId: string, actorId: string) {
|
||||
return { error: "Benutzer wurde nicht gefunden." } as const;
|
||||
}
|
||||
if (user.id === actorId) {
|
||||
return { error: "Das eigene Superadmin-Konto kann hier nicht geändert werden." } as const;
|
||||
return {
|
||||
error: "Das eigene Superadmin-Konto kann hier nicht geändert werden.",
|
||||
} as const;
|
||||
}
|
||||
if (user.role === UserRole.SUPERADMIN) {
|
||||
return { error: "Andere Superadmins werden in V1 nicht verwaltet." } as const;
|
||||
return {
|
||||
error: "Andere Superadmins werden in V1 nicht verwaltet.",
|
||||
} as const;
|
||||
}
|
||||
if (!user.kitaId) {
|
||||
return { error: "Nur tenant-gebundene Benutzer können verwaltet werden." } as const;
|
||||
return {
|
||||
error: "Nur tenant-gebundene Benutzer können verwaltet werden.",
|
||||
} as const;
|
||||
}
|
||||
|
||||
return { user } as const;
|
||||
@@ -188,8 +204,15 @@ export async function deleteUser(rawPayload: unknown): Promise<ActionResult> {
|
||||
};
|
||||
}
|
||||
|
||||
const attachmentIds = await prisma.$transaction(async (tx) => {
|
||||
const attachments = await tx.attachment.findMany({ where: { announcement: { authorId: user.id } }, select: { id: true } });
|
||||
let attachmentIds: string[];
|
||||
try {
|
||||
attachmentIds = await serializableTransaction(async (tx) => {
|
||||
await lockKitaAccounts(tx, user.kitaId!);
|
||||
await assertKitaAdminRemains(tx, user.kitaId!, [user.id]);
|
||||
const attachments = await tx.attachment.findMany({
|
||||
where: { announcement: { authorId: user.id } },
|
||||
select: { id: true },
|
||||
});
|
||||
await tx.verificationToken.deleteMany({
|
||||
where: { identifier: user.id },
|
||||
});
|
||||
@@ -213,9 +236,12 @@ export async function deleteUser(rawPayload: unknown): Promise<ActionResult> {
|
||||
},
|
||||
});
|
||||
await tx.user.delete({ where: { id: user.id } });
|
||||
return attachments.map(item => item.id);
|
||||
return attachments.map((item) => item.id);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (!(error instanceof UserFacingError)) throw error;
|
||||
return { ok: false, error: error.message };
|
||||
}
|
||||
await removeAttachmentFiles(attachmentIds);
|
||||
revalidatePath("/admin");
|
||||
revalidatePath(`/admin/kitas/${user.kitaId}`);
|
||||
@@ -245,10 +271,19 @@ async function updateUserSupportState(
|
||||
|
||||
const { user } = lookup;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
try {
|
||||
await serializableTransaction(async (tx) => {
|
||||
await lockKitaAccounts(tx, user.kitaId!);
|
||||
if (action === "USER_LOCK")
|
||||
await assertKitaAdminRemains(tx, user.kitaId!, [user.id]);
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: { ...data, ...(action === "USER_LOCK" ? { sessionVersion: { increment: 1 } } : {}) },
|
||||
data: {
|
||||
...data,
|
||||
...(action === "USER_LOCK"
|
||||
? { sessionVersion: { increment: 1 } }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
@@ -267,7 +302,10 @@ async function updateUserSupportState(
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (!(error instanceof UserFacingError)) throw error;
|
||||
return { ok: false, error: error.message };
|
||||
}
|
||||
revalidatePath("/admin");
|
||||
revalidatePath(`/admin/kitas/${user.kitaId}`);
|
||||
revalidatePath("/admin/audit");
|
||||
@@ -296,4 +334,3 @@ export async function resetFailedLoginAttempts(
|
||||
failedLoginAttempts: 0,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RoleChangeButton,
|
||||
type RolePerson,
|
||||
} from "../../zugriffsrechte/role-change-button";
|
||||
import { useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -47,6 +51,7 @@ type EducatorDto = {
|
||||
email: string | null;
|
||||
verified: boolean;
|
||||
inviteFailed: boolean;
|
||||
account: RolePerson | null;
|
||||
};
|
||||
|
||||
function AccessFields({
|
||||
@@ -119,9 +124,11 @@ function AccessFields({
|
||||
}
|
||||
|
||||
export function ErzieherList({
|
||||
currentUserId,
|
||||
educators,
|
||||
today,
|
||||
}: {
|
||||
currentUserId: string;
|
||||
educators: EducatorDto[];
|
||||
today: string;
|
||||
}) {
|
||||
@@ -377,6 +384,14 @@ export function ErzieherList({
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="pr-6 text-right">
|
||||
{ed.account ? (
|
||||
<span className="mr-2 inline-block">
|
||||
<RoleChangeButton
|
||||
person={ed.account}
|
||||
ownAccount={ed.account.id === currentUserId}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -21,7 +21,15 @@ export default async function ErzieherPage() {
|
||||
staffProfile: {
|
||||
select: {
|
||||
id: true,
|
||||
user: { select: { email: true, emailVerifiedAt: true } },
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
familyId: true,
|
||||
email: true,
|
||||
emailVerifiedAt: true,
|
||||
},
|
||||
},
|
||||
emails: {
|
||||
where: { inviteToken: { not: null }, skippedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
@@ -65,10 +73,20 @@ export default async function ErzieherPage() {
|
||||
</header>
|
||||
|
||||
<ErzieherList
|
||||
currentUserId={session.user.id}
|
||||
today={todayKey()}
|
||||
educators={educators.map(({ staffProfile, ...educator }) => ({
|
||||
...educator,
|
||||
hasProfile: !!staffProfile,
|
||||
account: staffProfile?.user
|
||||
? {
|
||||
id: staffProfile.user.id,
|
||||
name: `${educator.firstName} ${educator.lastName}`,
|
||||
role: staffProfile.user.role,
|
||||
familyId: staffProfile.user.familyId,
|
||||
hasStaffProfile: true,
|
||||
}
|
||||
: null,
|
||||
email: staffProfile?.user?.email ?? null,
|
||||
verified: !!staffProfile?.user?.emailVerifiedAt,
|
||||
inviteFailed: !!staffProfile?.emails[0]?.failed,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { UserRole } from "@prisma/client";
|
||||
import { RoleChangeButton } from "../../zugriffsrechte/role-change-button";
|
||||
import { RoleBadge } from "@/components/ui/role-badge";
|
||||
import { useState, useTransition } from "react";
|
||||
import { LayoutGrid, Mail, Search, Table, Users } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -29,6 +32,8 @@ interface FamilyUser {
|
||||
email: string;
|
||||
emailVerifiedAt: Date | null;
|
||||
invitation: FamilyInvitationView;
|
||||
role: UserRole;
|
||||
staffProfile: { id: string } | null;
|
||||
dutyAssignments: {
|
||||
id: string;
|
||||
dutyId: string;
|
||||
@@ -60,12 +65,14 @@ interface DutyData {
|
||||
}
|
||||
|
||||
interface FamiliesListClientProps {
|
||||
currentUserId: string;
|
||||
initialFamilies: FamilyData[];
|
||||
allDuties: DutyData[];
|
||||
canManageDuties: boolean;
|
||||
}
|
||||
|
||||
export function FamiliesListClient({
|
||||
currentUserId,
|
||||
initialFamilies,
|
||||
allDuties,
|
||||
canManageDuties,
|
||||
@@ -85,7 +92,7 @@ export function FamiliesListClient({
|
||||
(user) =>
|
||||
user.firstName.toLowerCase().includes(q) ||
|
||||
user.lastName.toLowerCase().includes(q) ||
|
||||
user.email.toLowerCase().includes(q)
|
||||
user.email.toLowerCase().includes(q),
|
||||
);
|
||||
if (matchesParent) return true;
|
||||
|
||||
@@ -93,20 +100,20 @@ export function FamiliesListClient({
|
||||
const matchesChild = family.children.some(
|
||||
(child) =>
|
||||
child.firstName.toLowerCase().includes(q) ||
|
||||
child.lastName.toLowerCase().includes(q)
|
||||
child.lastName.toLowerCase().includes(q),
|
||||
);
|
||||
return matchesChild;
|
||||
});
|
||||
|
||||
const childCount = filteredFamilies.reduce(
|
||||
(acc, family) => acc + family.children.length,
|
||||
0
|
||||
0,
|
||||
);
|
||||
|
||||
const pendingInviteCount = filteredFamilies.reduce(
|
||||
(acc, family) =>
|
||||
acc + family.users.filter((user) => !user.emailVerifiedAt).length,
|
||||
0
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -123,14 +130,17 @@ export function FamiliesListClient({
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Haushalte, Elternteile, Kinder und Ämter an einem ruhigen Ort pflegen.
|
||||
Haushalte, Elternteile, Kinder und Ämter an einem ruhigen Ort
|
||||
pflegen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setViewMode(viewMode === "table" ? "cards" : "table")}
|
||||
onClick={() =>
|
||||
setViewMode(viewMode === "table" ? "cards" : "table")
|
||||
}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{viewMode === "table" ? (
|
||||
@@ -152,7 +162,9 @@ export function FamiliesListClient({
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
<span className="text-[var(--ink)] tabular-nums">{filteredFamilies.length}</span>
|
||||
<span className="text-[var(--ink)] tabular-nums">
|
||||
{filteredFamilies.length}
|
||||
</span>
|
||||
Familien
|
||||
</span>
|
||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
@@ -160,7 +172,9 @@ export function FamiliesListClient({
|
||||
Kinder
|
||||
</span>
|
||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
<span className="text-[var(--ink)] tabular-nums">{pendingInviteCount}</span>
|
||||
<span className="text-[var(--ink)] tabular-nums">
|
||||
{pendingInviteCount}
|
||||
</span>
|
||||
Offene Einladungen
|
||||
</span>
|
||||
</div>
|
||||
@@ -196,7 +210,8 @@ export function FamiliesListClient({
|
||||
<div>
|
||||
<CardTitle>Haushalte</CardTitle>
|
||||
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
||||
{filteredFamilies.length} {filteredFamilies.length === 1 ? "Familie" : "Familien"} ·{" "}
|
||||
{filteredFamilies.length}{" "}
|
||||
{filteredFamilies.length === 1 ? "Familie" : "Familien"} ·{" "}
|
||||
{childCount} Kinder · {pendingInviteCount} offene Einladungen
|
||||
</p>
|
||||
</div>
|
||||
@@ -218,7 +233,7 @@ export function FamiliesListClient({
|
||||
<TableBody>
|
||||
{filteredFamilies.map((family) => {
|
||||
const hasPendingInvite = family.users.some(
|
||||
(user) => !user.emailVerifiedAt
|
||||
(user) => !user.emailVerifiedAt,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -250,10 +265,29 @@ export function FamiliesListClient({
|
||||
<Mail className="h-3 w-3 shrink-0 [stroke-width:1.7]" />
|
||||
<span className="break-words [overflow-wrap:anywhere]">
|
||||
{parent.email}
|
||||
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
|
||||
{!parent.emailVerifiedAt && (
|
||||
<ResendInviteButton userId={parent.id} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<InvitationStatus userId={parent.id} name={`${parent.firstName} ${parent.lastName}`} invitation={parent.invitation} />
|
||||
<InvitationStatus
|
||||
userId={parent.id}
|
||||
name={`${parent.firstName} ${parent.lastName}`}
|
||||
invitation={parent.invitation}
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<RoleBadge role={parent.role} />
|
||||
<RoleChangeButton
|
||||
ownAccount={parent.id === currentUserId}
|
||||
person={{
|
||||
id: parent.id,
|
||||
name: `${parent.firstName} ${parent.lastName}`,
|
||||
role: parent.role,
|
||||
familyId: family.id,
|
||||
hasStaffProfile: !!parent.staffProfile,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -277,7 +311,9 @@ export function FamiliesListClient({
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={hasPendingInvite ? "warning" : "success"}>
|
||||
<Badge
|
||||
variant={hasPendingInvite ? "warning" : "success"}
|
||||
>
|
||||
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
@@ -323,7 +359,7 @@ export function FamiliesListClient({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredFamilies.map((family) => {
|
||||
const hasPendingInvite = family.users.some(
|
||||
(user) => !user.emailVerifiedAt
|
||||
(user) => !user.emailVerifiedAt,
|
||||
);
|
||||
return (
|
||||
<Card key={family.id} className="flex flex-col h-full">
|
||||
@@ -333,9 +369,12 @@ export function FamiliesListClient({
|
||||
{family.name.slice(0, 1)}
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg font-semibold">{family.name}</CardTitle>
|
||||
<CardTitle className="text-lg font-semibold">
|
||||
{family.name}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
{family.users.length} Elternteil{family.users.length === 1 ? "" : "e"}
|
||||
{family.users.length} Elternteil
|
||||
{family.users.length === 1 ? "" : "e"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -359,10 +398,29 @@ export function FamiliesListClient({
|
||||
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
|
||||
<span className="break-words [overflow-wrap:anywhere]">
|
||||
{parent.email}
|
||||
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
|
||||
{!parent.emailVerifiedAt && (
|
||||
<ResendInviteButton userId={parent.id} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<InvitationStatus userId={parent.id} name={`${parent.firstName} ${parent.lastName}`} invitation={parent.invitation} />
|
||||
<InvitationStatus
|
||||
userId={parent.id}
|
||||
name={`${parent.firstName} ${parent.lastName}`}
|
||||
invitation={parent.invitation}
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<RoleBadge role={parent.role} />
|
||||
<RoleChangeButton
|
||||
ownAccount={parent.id === currentUserId}
|
||||
person={{
|
||||
id: parent.id,
|
||||
name: `${parent.firstName} ${parent.lastName}`,
|
||||
role: parent.role,
|
||||
familyId: family.id,
|
||||
hasStaffProfile: !!parent.staffProfile,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -430,8 +488,21 @@ export function FamiliesListClient({
|
||||
|
||||
function ResendInviteButton({ userId }: { userId: string }) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
return <Button type="button" size="sm" variant="ghost" disabled={pending} onClick={() => startTransition(async () => {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
const result = await resendParentInvite(userId);
|
||||
if (result.error) toast.error(result.error); else toast.success("Einladung erneut gesendet.");
|
||||
})}>{pending ? "Wird gesendet…" : "Einladung erneut senden"}</Button>;
|
||||
if (result.error) toast.error(result.error);
|
||||
else toast.success("Einladung erneut gesendet.");
|
||||
})
|
||||
}
|
||||
>
|
||||
{pending ? "Wird gesendet…" : "Einladung erneut senden"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
queueFamilyInvitation,
|
||||
resendFamilyInvitation,
|
||||
} from "@/lib/family-invitations";
|
||||
import {
|
||||
assertKitaAdminRemains,
|
||||
lockKitaAccounts,
|
||||
} from "@/lib/kita-admin-guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { assertChildrenRemovable } from "@/lib/notdienst-planning";
|
||||
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||
@@ -149,7 +153,12 @@ export async function addFamilyAction(
|
||||
return { errors: { _form: ["Kein Mandant zugeordnet."] } };
|
||||
}
|
||||
|
||||
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { errors: { _form: ["Zu viele Einladungen. Bitte versuche es später erneut."] } };
|
||||
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed)
|
||||
return {
|
||||
errors: {
|
||||
_form: ["Zu viele Einladungen. Bitte versuche es später erneut."],
|
||||
},
|
||||
};
|
||||
|
||||
const parsedFamily = addFamilySchema.safeParse(Object.fromEntries(formData));
|
||||
if (!parsedFamily.success) {
|
||||
@@ -310,8 +319,11 @@ export async function updateFamilyAction(
|
||||
}
|
||||
|
||||
const removedChildIdSet = new Set(removedChildIds);
|
||||
const activeChildren = children.filter((child) => !removedChildIdSet.has(child.id));
|
||||
if (activeChildren.length + newChildren.length > 20) return { error: "Maximal 20 Kinder pro Familie." };
|
||||
const activeChildren = children.filter(
|
||||
(child) => !removedChildIdSet.has(child.id),
|
||||
);
|
||||
if (activeChildren.length + newChildren.length > 20)
|
||||
return { error: "Maximal 20 Kinder pro Familie." };
|
||||
if (activeChildren.length + newChildren.length === 0) {
|
||||
return { error: "Eine Familie benötigt mindestens ein Kind." };
|
||||
}
|
||||
@@ -341,13 +353,26 @@ export async function updateFamilyAction(
|
||||
return { error: "Familie wurde nicht gefunden." };
|
||||
}
|
||||
|
||||
const needsEmail = newParents.length > 0 || parents.some(parent => existingFamily.users.find(user => user.id === parent.id)?.email !== parent.email);
|
||||
const needsEmail =
|
||||
newParents.length > 0 ||
|
||||
parents.some(
|
||||
(parent) =>
|
||||
existingFamily.users.find((user) => user.id === parent.id)?.email !==
|
||||
parent.email,
|
||||
);
|
||||
if (needsEmail) {
|
||||
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { error: "Zu viele Einladungen. Bitte versuche es später erneut." };
|
||||
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed)
|
||||
return {
|
||||
error: "Zu viele Einladungen. Bitte versuche es später erneut.",
|
||||
};
|
||||
}
|
||||
|
||||
const existingParentIds = new Set(existingFamily.users.map((user) => user.id));
|
||||
const existingChildIds = new Set(existingFamily.children.map((child) => child.id));
|
||||
const existingParentIds = new Set(
|
||||
existingFamily.users.map((user) => user.id),
|
||||
);
|
||||
const existingChildIds = new Set(
|
||||
existingFamily.children.map((child) => child.id),
|
||||
);
|
||||
|
||||
if (!parents.every((parent) => existingParentIds.has(parent.id))) {
|
||||
return {
|
||||
@@ -367,23 +392,51 @@ export async function updateFamilyAction(
|
||||
return { error: "Pro Familie sind maximal zwei Elternteile vorgesehen." };
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await lockKitaAccounts(tx, kitaId);
|
||||
const changedEmails = parents
|
||||
.filter(
|
||||
(parent) =>
|
||||
existingFamily.users.find((user) => user.id === parent.id)
|
||||
?.email !== parent.email,
|
||||
)
|
||||
.map((parent) => parent.id);
|
||||
await assertKitaAdminRemains(tx, kitaId, changedEmails);
|
||||
await assertChildrenRemovable(tx, kitaId, familyId, removedChildIds);
|
||||
const removed = await tx.child.count({ where: { kitaId, familyId, id: { in: removedChildIds } } });
|
||||
if (removed !== removedChildIds.length) throw new UserFacingError("Mindestens ein Kind gehört nicht zu dieser Familie.");
|
||||
const removed = await tx.child.count({
|
||||
where: { kitaId, familyId, id: { in: removedChildIds } },
|
||||
});
|
||||
if (removed !== removedChildIds.length)
|
||||
throw new UserFacingError(
|
||||
"Mindestens ein Kind gehört nicht zu dieser Familie.",
|
||||
);
|
||||
await tx.family.update({
|
||||
where: { id: familyId },
|
||||
data: { name: familyName },
|
||||
});
|
||||
|
||||
for (const parent of parents) {
|
||||
const current = await tx.user.findFirst({ where: { id: parent.id, kitaId, familyId }, select: { email: true } });
|
||||
const current = await tx.user.findFirst({
|
||||
where: { id: parent.id, kitaId, familyId },
|
||||
select: { email: true },
|
||||
});
|
||||
if (!current) throw new Error("Parent assignment changed");
|
||||
const emailChanged = current.email !== parent.email;
|
||||
await tx.user.update({
|
||||
where: { id: parent.id, kitaId, familyId },
|
||||
data: { firstName: parent.firstName, lastName: parent.lastName, email: parent.email,
|
||||
...(emailChanged ? { passwordHash: "", emailVerifiedAt: null, sessionVersion: { increment: 1 } } : {}) },
|
||||
data: {
|
||||
firstName: parent.firstName,
|
||||
lastName: parent.lastName,
|
||||
email: parent.email,
|
||||
...(emailChanged
|
||||
? {
|
||||
passwordHash: "",
|
||||
emailVerifiedAt: null,
|
||||
sessionVersion: { increment: 1 },
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
if (emailChanged) {
|
||||
const invite = await queueFamilyInvitation(
|
||||
@@ -439,7 +492,9 @@ export async function updateFamilyAction(
|
||||
},
|
||||
});
|
||||
}
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
@@ -448,8 +503,14 @@ export async function updateFamilyAction(
|
||||
return { error: "Mindestens eine E-Mail-Adresse existiert bereits." };
|
||||
}
|
||||
|
||||
if (!(error instanceof UserFacingError)) console.error("Fehler beim Aktualisieren der Familie:", error);
|
||||
return { error: publicActionError(error, "Familie konnte nicht aktualisiert werden.") };
|
||||
if (!(error instanceof UserFacingError))
|
||||
console.error("Fehler beim Aktualisieren der Familie:", error);
|
||||
return {
|
||||
error: publicActionError(
|
||||
error,
|
||||
"Familie konnte nicht aktualisiert werden.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const warning = await sendInviteJobs(inviteJobs);
|
||||
@@ -464,15 +525,33 @@ export async function resendFamilyInvitationAction(
|
||||
userId: string,
|
||||
): Promise<{ success?: boolean; error?: string; warning?: string }> {
|
||||
const session = await requireRole([UserRole.ADMIN]);
|
||||
if (!session.user.kitaId || typeof userId !== "string" || !userId || userId.length > 64)
|
||||
if (
|
||||
!session.user.kitaId ||
|
||||
typeof userId !== "string" ||
|
||||
!userId ||
|
||||
userId.length > 64
|
||||
)
|
||||
return { error: "Ungültiges Elternkonto." };
|
||||
if (!(await rateLimit(`family-invites:${session.user.kitaId}`, 30, 3600)).allowed)
|
||||
if (
|
||||
!(await rateLimit(`family-invites:${session.user.kitaId}`, 30, 3600))
|
||||
.allowed
|
||||
)
|
||||
return { error: "Bitte versuche es später erneut." };
|
||||
if (!(await rateLimit(`resend-invite:${session.user.kitaId}:${userId}`, 3, 3600)).allowed)
|
||||
if (
|
||||
!(
|
||||
await rateLimit(`resend-invite:${session.user.kitaId}:${userId}`, 3, 3600)
|
||||
).allowed
|
||||
)
|
||||
return { error: "Bitte versuche es später erneut." };
|
||||
const recipient = await prisma.user.findFirst({
|
||||
where: { id: userId, kitaId: session.user.kitaId, familyId: { not: null },
|
||||
emailVerifiedAt: null, passwordHash: "", disabledAt: null },
|
||||
where: {
|
||||
id: userId,
|
||||
kitaId: session.user.kitaId,
|
||||
familyId: { not: null },
|
||||
emailVerifiedAt: null,
|
||||
passwordHash: "",
|
||||
disabledAt: null,
|
||||
},
|
||||
select: { email: true },
|
||||
});
|
||||
if (!recipient) return { error: "Keine offene Einladung gefunden." };
|
||||
|
||||
@@ -16,7 +16,12 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
import { createDuty, assignDuty, removeDutyAssignment, deleteDuty } from "./duty-actions";
|
||||
import {
|
||||
createDuty,
|
||||
assignDuty,
|
||||
removeDutyAssignment,
|
||||
deleteDuty,
|
||||
} from "./duty-actions";
|
||||
|
||||
type DutyWithAssignments = {
|
||||
id: string;
|
||||
@@ -100,17 +105,25 @@ export function DutyManager({
|
||||
};
|
||||
|
||||
const unassignedDuties = allDuties.filter(
|
||||
(d) => !userAssignments.some((a) => a.dutyId === d.id)
|
||||
(d) => !userAssignments.some((a) => a.dutyId === d.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1"
|
||||
aria-label={`Ämter für ${user.firstName} ${user.lastName}`}
|
||||
>
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Ämter</span>
|
||||
<span>Ämter: {user.firstName}</span>
|
||||
{userAssignments.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 h-4 px-1 py-0 tabular-nums">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1 h-4 px-1 py-0 tabular-nums"
|
||||
>
|
||||
{userAssignments.length}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -137,7 +150,11 @@ export function DutyManager({
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{userAssignments.map((assignment) => (
|
||||
<Badge key={assignment.id} variant="default" className="gap-1 pr-1">
|
||||
<Badge
|
||||
key={assignment.id}
|
||||
variant="default"
|
||||
className="gap-1 pr-1"
|
||||
>
|
||||
{assignment.duty.name}
|
||||
<button
|
||||
className="ml-1 rounded-full p-0.5 transition-colors hover:bg-[oklch(0.98_0.005_45_/_0.18)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[oklch(0.98_0.005_45)]"
|
||||
@@ -199,7 +216,12 @@ export function DutyManager({
|
||||
Neues Amt anlegen
|
||||
</h4>
|
||||
<form action={handleCreateDuty} className="flex gap-2">
|
||||
<Input name="name" placeholder="z.B. Wäschedienst" className="h-8" required />
|
||||
<Input
|
||||
name="name"
|
||||
placeholder="z.B. Wäschedienst"
|
||||
className="h-8"
|
||||
required
|
||||
/>
|
||||
<Button size="sm" className="h-8" disabled={isPending}>
|
||||
Erstellen
|
||||
</Button>
|
||||
|
||||
@@ -32,9 +32,17 @@ export default async function FamiliesPage() {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
role: true,
|
||||
staffProfile: { select: { id: true } },
|
||||
emailVerifiedAt: true,
|
||||
familyInvitation: {
|
||||
select: { expiresAt: true, sentAt: true, failed: true, leaseUntil: true, status: true },
|
||||
select: {
|
||||
expiresAt: true,
|
||||
sentAt: true,
|
||||
failed: true,
|
||||
leaseUntil: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
dutyAssignments: {
|
||||
select: {
|
||||
@@ -77,11 +85,15 @@ export default async function FamiliesPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<FamiliesListClient
|
||||
currentUserId={session.user.id}
|
||||
initialFamilies={families.map((family) => ({
|
||||
...family,
|
||||
users: family.users.map(({ familyInvitation, ...parent }) => ({
|
||||
...parent,
|
||||
invitation: familyInvitationView(parent.emailVerifiedAt, familyInvitation),
|
||||
invitation: familyInvitationView(
|
||||
parent.emailVerifiedAt,
|
||||
familyInvitation,
|
||||
),
|
||||
})),
|
||||
}))}
|
||||
allDuties={allDuties}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useTransition } from "react";
|
||||
import {
|
||||
TerminType,
|
||||
TerminStatus,
|
||||
} from "@prisma/client";
|
||||
import { TerminType, TerminStatus } from "@prisma/client";
|
||||
import {
|
||||
format,
|
||||
startOfMonth,
|
||||
@@ -30,7 +27,7 @@ import {
|
||||
WashingMachine,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { RsvpDialog, RsvpForm } from "@/components/rsvp-form";
|
||||
import { RsvpForm } from "@/components/rsvp-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -42,7 +39,6 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -64,12 +60,32 @@ import {
|
||||
const categories = [
|
||||
{ label: "Schließtag", type: TerminType.SCHLIESSTAG, color: "var(--danger)" },
|
||||
{ label: "Teamtag", type: TerminType.TEAMTAG, color: "var(--danger)" },
|
||||
{ label: "Elternabend", type: TerminType.ELTERNABEND, color: "oklch(0.50 0.10 280)" },
|
||||
{
|
||||
label: "Elternabend",
|
||||
type: TerminType.ELTERNABEND,
|
||||
color: "oklch(0.50 0.10 280)",
|
||||
},
|
||||
{ label: "Fest", type: TerminType.KITA_FEST, color: "var(--good)" },
|
||||
{ label: "Geburtstag", type: TerminType.GEBURTSTAG_INTERN, color: "oklch(0.50 0.10 320)" },
|
||||
{ label: "Mein Elterndienst", type: "mein-elterndienst", color: "oklch(0.60 0.16 160)" },
|
||||
{ label: "Elterndienst", type: "elterndienst", color: "oklch(0.75 0.08 160)" },
|
||||
{ label: "Mein Notdienst", type: "mein-notdienst", color: "oklch(0.60 0.18 45)" },
|
||||
{
|
||||
label: "Geburtstag",
|
||||
type: TerminType.GEBURTSTAG_INTERN,
|
||||
color: "oklch(0.50 0.10 320)",
|
||||
},
|
||||
{
|
||||
label: "Mein Elterndienst",
|
||||
type: "mein-elterndienst",
|
||||
color: "oklch(0.60 0.16 160)",
|
||||
},
|
||||
{
|
||||
label: "Elterndienst",
|
||||
type: "elterndienst",
|
||||
color: "oklch(0.75 0.08 160)",
|
||||
},
|
||||
{
|
||||
label: "Mein Notdienst",
|
||||
type: "mein-notdienst",
|
||||
color: "oklch(0.60 0.18 45)",
|
||||
},
|
||||
{ label: "Notdienst", type: "notdienst", color: "oklch(0.75 0.10 50)" },
|
||||
{ label: "Sonstiges", type: TerminType.SONSTIGES, color: "var(--ink-muted)" },
|
||||
];
|
||||
@@ -102,7 +118,9 @@ export function CalendarOverviewClient({
|
||||
const [view, setView] = useState<"liste" | "monat" | "agenda">("monat");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeTypes, setActiveTypes] = useState<Set<string>>(new Set());
|
||||
const [filterScope, setFilterScope] = useState<"own" | "all">(isAdmin ? "all" : "own");
|
||||
const [filterScope, setFilterScope] = useState<"own" | "all">(
|
||||
isAdmin ? "all" : "own",
|
||||
);
|
||||
|
||||
// Month navigation state
|
||||
const [currentMonthDate, setCurrentMonthDate] = useState(new Date());
|
||||
@@ -152,7 +170,8 @@ export function CalendarOverviewClient({
|
||||
item.kind === "notdienst" && item.childName
|
||||
? item.childName.toLowerCase().includes(query)
|
||||
: false;
|
||||
if (!titleMatch && !descMatch && !familyMatch && !childMatch) return false;
|
||||
if (!titleMatch && !descMatch && !familyMatch && !childMatch)
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Category filter
|
||||
@@ -178,9 +197,21 @@ export function CalendarOverviewClient({
|
||||
const isEventOnDay = (item: CalendarListItemDto, day: Date) => {
|
||||
const eventStart = new Date(item.startDate);
|
||||
const eventEnd = new Date(item.endDate);
|
||||
const dayTime = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime();
|
||||
const startTime = new Date(eventStart.getFullYear(), eventStart.getMonth(), eventStart.getDate()).getTime();
|
||||
const endTime = new Date(eventEnd.getFullYear(), eventEnd.getMonth(), eventEnd.getDate()).getTime();
|
||||
const dayTime = new Date(
|
||||
day.getFullYear(),
|
||||
day.getMonth(),
|
||||
day.getDate(),
|
||||
).getTime();
|
||||
const startTime = new Date(
|
||||
eventStart.getFullYear(),
|
||||
eventStart.getMonth(),
|
||||
eventStart.getDate(),
|
||||
).getTime();
|
||||
const endTime = new Date(
|
||||
eventEnd.getFullYear(),
|
||||
eventEnd.getMonth(),
|
||||
eventEnd.getDate(),
|
||||
).getTime();
|
||||
return dayTime >= startTime && dayTime <= endTime;
|
||||
};
|
||||
|
||||
@@ -330,7 +361,7 @@ export function CalendarOverviewClient({
|
||||
<AgendaView
|
||||
events={filteredEvents}
|
||||
isAdmin={isAdmin}
|
||||
userId={userId}
|
||||
onOpenDetails={(item) => setSelectedDay(new Date(item.startDate))}
|
||||
onDeleteTermin={(id, title) => {
|
||||
setDeleteEventId(id);
|
||||
setDeleteEventTitle(title);
|
||||
@@ -339,11 +370,15 @@ export function CalendarOverviewClient({
|
||||
)}
|
||||
|
||||
{/* Day details Dialog for Month View */}
|
||||
<Dialog open={!!selectedDay} onOpenChange={(open) => !open && setSelectedDay(null)}>
|
||||
<Dialog
|
||||
open={!!selectedDay}
|
||||
onOpenChange={(open) => !open && setSelectedDay(null)}
|
||||
>
|
||||
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">
|
||||
{selectedDay && format(selectedDay, "EEEE, d. MMMM yyyy", { locale: de })}
|
||||
{selectedDay &&
|
||||
format(selectedDay, "EEEE, d. MMMM yyyy", { locale: de })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Übersicht aller Zuteilungen, Feste und Einträge an diesem Tag.
|
||||
@@ -372,7 +407,14 @@ export function CalendarOverviewClient({
|
||||
}}
|
||||
/>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--surface-2)] text-[var(--ink-soft)] ml-2">
|
||||
<WashingMachine className="h-5 w-5 [stroke-width:1.7]" style={{ color: item.isOwn ? "oklch(0.60 0.16 160)" : "oklch(0.75 0.08 160)" }} />
|
||||
<WashingMachine
|
||||
className="h-5 w-5 [stroke-width:1.7]"
|
||||
style={{
|
||||
color: item.isOwn
|
||||
? "oklch(0.60 0.16 160)"
|
||||
: "oklch(0.75 0.08 160)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -410,7 +452,11 @@ export function CalendarOverviewClient({
|
||||
{isAdmin && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<a href="/dashboard/admin/dienste">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs px-2.5"
|
||||
>
|
||||
Im Planer bearbeiten
|
||||
</Button>
|
||||
</a>
|
||||
@@ -462,8 +508,14 @@ export function CalendarOverviewClient({
|
||||
</p>
|
||||
{isAdmin && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<a href={`/dashboard/notdienst/plan?year=${item.planYear}&month=${item.planMonth}`}>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
|
||||
<a
|
||||
href={`/dashboard/notdienst/plan?year=${item.planYear}&month=${item.planMonth}`}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs px-2.5"
|
||||
>
|
||||
Im Planer bearbeiten
|
||||
</Button>
|
||||
</a>
|
||||
@@ -475,7 +527,11 @@ export function CalendarOverviewClient({
|
||||
target={item}
|
||||
options={notdienstSwapOptions}
|
||||
trigger={
|
||||
<Button size="sm" variant="outline" className="h-7 px-2.5 text-xs">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
>
|
||||
Tausch anfragen
|
||||
</Button>
|
||||
}
|
||||
@@ -500,7 +556,8 @@ export function CalendarOverviewClient({
|
||||
style={{
|
||||
color: getCategoryColor(item.type),
|
||||
borderColor: getCategoryColor(item.type) + "44",
|
||||
backgroundColor: getCategoryColor(item.type) + "11",
|
||||
backgroundColor:
|
||||
getCategoryColor(item.type) + "11",
|
||||
}}
|
||||
>
|
||||
{getTypeLabel(item.type)}
|
||||
@@ -523,10 +580,17 @@ export function CalendarOverviewClient({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{item.rsvpEvent ? <section className="mt-4 border-t border-[var(--hairline)] pt-4">
|
||||
<h5 className="mb-3 font-medium">Eure Rückmeldung</h5>
|
||||
<RsvpForm key={JSON.stringify(item.rsvpEvent)} event={item.rsvpEvent} />
|
||||
</section> : null}
|
||||
{item.rsvpEvent ? (
|
||||
<section className="mt-4 border-t border-[var(--hairline)] pt-4">
|
||||
<h5 className="mb-3 font-medium">
|
||||
Eure Rückmeldung
|
||||
</h5>
|
||||
<RsvpForm
|
||||
key={JSON.stringify(item.rsvpEvent)}
|
||||
event={item.rsvpEvent}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{item.participantInfo && item.showParticipantInfo && (
|
||||
<div className="mt-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-3 text-xs text-[var(--ink)]">
|
||||
@@ -534,7 +598,9 @@ export function CalendarOverviewClient({
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-[var(--warn)]" />
|
||||
Hinweis für Teilnehmer
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap">{item.participantInfo}</p>
|
||||
<p className="whitespace-pre-wrap">
|
||||
{item.participantInfo}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -582,13 +648,17 @@ export function CalendarOverviewClient({
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={!!deleteEventId} onOpenChange={(open) => !open && setDeleteEventId(null)}>
|
||||
<Dialog
|
||||
open={!!deleteEventId}
|
||||
onOpenChange={(open) => !open && setDeleteEventId(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Möchtest du den Termin “{deleteEventTitle}” wirklich löschen? Alle
|
||||
zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren.
|
||||
Möchtest du den Termin “{deleteEventTitle}” wirklich
|
||||
löschen? Alle zugehörigen Teilnahmen und Mitbringsel-Einträge
|
||||
gehen verloren.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -642,7 +712,9 @@ function NotdienstSwapRequestRow({
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const runAction = (
|
||||
action: (requestId: string) => Promise<{ success?: boolean; error?: string }>,
|
||||
action: (
|
||||
requestId: string,
|
||||
) => Promise<{ success?: boolean; error?: string }>,
|
||||
successMessage: string,
|
||||
) => {
|
||||
startTransition(async () => {
|
||||
@@ -659,13 +731,15 @@ function NotdienstSwapRequestRow({
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-[var(--ink)]">
|
||||
{request.direction === "incoming" ? "Anfrage an dich" : "Deine Anfrage"}
|
||||
{request.direction === "incoming"
|
||||
? "Anfrage an dich"
|
||||
: "Deine Anfrage"}
|
||||
</p>
|
||||
<p className="mt-1 text-sm leading-5 text-[var(--ink-soft)]">
|
||||
{format(request.targetDate, "dd.MM.yyyy", { locale: de })}{" "}
|
||||
({request.targetFamilyName}, {request.targetChildName}) gegen{" "}
|
||||
{format(request.offeredDate, "dd.MM.yyyy", { locale: de })}{" "}
|
||||
({request.offeredFamilyName}, {request.offeredChildName})
|
||||
{format(request.targetDate, "dd.MM.yyyy", { locale: de })} (
|
||||
{request.targetFamilyName}, {request.targetChildName}) gegen{" "}
|
||||
{format(request.offeredDate, "dd.MM.yyyy", { locale: de })} (
|
||||
{request.offeredFamilyName}, {request.offeredChildName})
|
||||
</p>
|
||||
{request.message && (
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
|
||||
@@ -679,7 +753,10 @@ function NotdienstSwapRequestRow({
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
runAction(acceptNotdienstSwapRequest, "Tausch wurde angenommen.")
|
||||
runAction(
|
||||
acceptNotdienstSwapRequest,
|
||||
"Tausch wurde angenommen.",
|
||||
)
|
||||
}
|
||||
disabled={isPending}
|
||||
>
|
||||
@@ -689,7 +766,10 @@ function NotdienstSwapRequestRow({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
runAction(declineNotdienstSwapRequest, "Tausch wurde abgelehnt.")
|
||||
runAction(
|
||||
declineNotdienstSwapRequest,
|
||||
"Tausch wurde abgelehnt.",
|
||||
)
|
||||
}
|
||||
disabled={isPending}
|
||||
>
|
||||
@@ -701,7 +781,10 @@ function NotdienstSwapRequestRow({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
runAction(cancelNotdienstSwapRequest, "Tauschanfrage wurde zurückgezogen.")
|
||||
runAction(
|
||||
cancelNotdienstSwapRequest,
|
||||
"Tauschanfrage wurde zurückgezogen.",
|
||||
)
|
||||
}
|
||||
disabled={isPending}
|
||||
>
|
||||
@@ -791,7 +874,9 @@ function MonthView({
|
||||
onClick={() => onDayClick(day)}
|
||||
className={[
|
||||
"min-h-[105px] bg-[var(--surface)] p-2 transition-colors hover:bg-[var(--surface-2)] cursor-pointer flex flex-col justify-between",
|
||||
!isCurrentMonth ? "bg-[var(--surface-3)]/30 text-[var(--ink-muted)] opacity-60" : "text-[var(--ink)]",
|
||||
!isCurrentMonth
|
||||
? "bg-[var(--surface-3)]/30 text-[var(--ink-muted)] opacity-60"
|
||||
: "text-[var(--ink)]",
|
||||
].join(" ")}
|
||||
>
|
||||
{/* Day number cell top */}
|
||||
@@ -856,25 +941,33 @@ function MonthView({
|
||||
function AgendaView({
|
||||
events,
|
||||
isAdmin,
|
||||
userId,
|
||||
onDeleteTermin,
|
||||
onOpenDetails,
|
||||
}: {
|
||||
events: CalendarListItemDto[];
|
||||
isAdmin: boolean;
|
||||
userId: string;
|
||||
onDeleteTermin: (id: string, title: string) => void;
|
||||
onOpenDetails: (item: CalendarListItemDto) => void;
|
||||
}) {
|
||||
const grouped = useMemo(() => {
|
||||
const groups = new Map<string, { date: Date; items: CalendarListItemDto[] }>();
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ date: Date; items: CalendarListItemDto[] }
|
||||
>();
|
||||
|
||||
events.forEach((item) => {
|
||||
const key = format(new Date(item.startDate), "yyyy-MM-dd");
|
||||
const group = groups.get(key) ?? { date: new Date(item.startDate), items: [] };
|
||||
const group = groups.get(key) ?? {
|
||||
date: new Date(item.startDate),
|
||||
items: [],
|
||||
};
|
||||
group.items.push(item);
|
||||
groups.set(key, group);
|
||||
});
|
||||
|
||||
return Array.from(groups.values()).sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
return Array.from(groups.values()).sort(
|
||||
(a, b) => a.date.getTime() - b.date.getTime(),
|
||||
);
|
||||
}, [events]);
|
||||
|
||||
if (events.length === 0) {
|
||||
@@ -896,7 +989,10 @@ function AgendaView({
|
||||
return (
|
||||
<div className="space-y-6 pl-4 border-l-2 border-[var(--hairline)]">
|
||||
{grouped.map((group) => (
|
||||
<div key={group.date.toString()} className="relative grid grid-cols-[110px_1fr] gap-4 items-start">
|
||||
<div
|
||||
key={group.date.toString()}
|
||||
className="relative grid grid-cols-1 sm:grid-cols-[110px_minmax(0,1fr)] gap-4 items-start"
|
||||
>
|
||||
{/* Timeline marker */}
|
||||
<div className="absolute -left-[21px] top-1.5 h-3.5 w-3.5 rounded-full border-2 border-[var(--accent-deep)] bg-[var(--surface)]" />
|
||||
|
||||
@@ -929,7 +1025,7 @@ function AgendaView({
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="group relative flex items-start justify-between gap-4 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm transition-shadow hover:shadow-md"
|
||||
className="group relative flex flex-wrap items-start justify-between gap-4 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div
|
||||
@@ -965,12 +1061,18 @@ function AgendaView({
|
||||
{item.isOwn ? "Mein Notdienst" : "Notdienst"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)] text-[10px]">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)] text-[10px]"
|
||||
>
|
||||
Elterndienst
|
||||
</Badge>
|
||||
)}
|
||||
{item.kind === "termin" && item.status === TerminStatus.PENDING && (
|
||||
<Badge variant="warning" className="text-[10px]">Ausstehend</Badge>
|
||||
{item.kind === "termin" &&
|
||||
item.status === TerminStatus.PENDING && (
|
||||
<Badge variant="warning" className="text-[10px]">
|
||||
Ausstehend
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -989,7 +1091,9 @@ function AgendaView({
|
||||
new Date(item.endDate),
|
||||
"dd.MM.yyyy",
|
||||
)}`}
|
||||
{item.kind === "termin" && <MapPin className="ml-1 h-3.5 w-3.5" />}
|
||||
{item.kind === "termin" && (
|
||||
<MapPin className="ml-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{item.kind === "termin"
|
||||
? "Kita"
|
||||
: item.kind === "notdienst"
|
||||
@@ -1005,7 +1109,14 @@ function AgendaView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.kind === "termin" && item.rsvpEvent ? <RsvpDialog event={item.rsvpEvent} /> : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label={`Details zu ${item.title}`}
|
||||
onClick={() => onOpenDetails(item)}
|
||||
>
|
||||
Details
|
||||
</Button>
|
||||
|
||||
{/* Actions for admins */}
|
||||
{isAdmin && item.kind === "termin" && (
|
||||
|
||||
@@ -15,7 +15,11 @@ import { getTargetMonthData } from "@/lib/date-utils";
|
||||
import { loadNotdienstDays } from "@/lib/notdienst-planning";
|
||||
import { todayKey } from "@/lib/alarms/format";
|
||||
import { serializableTransaction } from "@/lib/transaction";
|
||||
import { queueNotdienstPublication, queueNotdienstReminders, deliverDueNotdienstEmails } from "@/lib/notdienst-notifications";
|
||||
import {
|
||||
queueNotdienstPublication,
|
||||
queueNotdienstReminders,
|
||||
deliverDueNotdienstEmails,
|
||||
} from "@/lib/notdienst-notifications";
|
||||
import { sendAppEmail } from "@/lib/mail";
|
||||
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
|
||||
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||
@@ -43,14 +47,20 @@ export async function generatePlanAction(year?: number, month?: number) {
|
||||
targetMonth = data.targetMonth;
|
||||
}
|
||||
|
||||
if (!validPlanMonth(targetYear, targetMonth)) return { error: "Ungültiger Planungsmonat." };
|
||||
if (!validPlanMonth(targetYear, targetMonth))
|
||||
return { error: "Ungültiger Planungsmonat." };
|
||||
|
||||
try {
|
||||
await serializableTransaction(async (tx) => {
|
||||
const existingPlan = await tx.notdienstPlan.findUnique({
|
||||
where: { kitaId_year_month: { kitaId, year: targetYear, month: targetMonth } },
|
||||
where: {
|
||||
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
|
||||
},
|
||||
});
|
||||
if (existingPlan) throw new UserFacingError("Ein Plan für diesen Monat existiert bereits.");
|
||||
if (existingPlan)
|
||||
throw new UserFacingError(
|
||||
"Ein Plan für diesen Monat existiert bereits.",
|
||||
);
|
||||
|
||||
const [availabilities, workingDays] = await Promise.all([
|
||||
tx.notdienstAvailability.findMany({
|
||||
@@ -69,16 +79,29 @@ export async function generatePlanAction(year?: number, month?: number) {
|
||||
const familyUsageCount = new Map<string, number>();
|
||||
const assignments: { childId: string; date: Date }[] = [];
|
||||
for (const day of workingDays) {
|
||||
const candidates = availabilities.filter((a) => a.date.getTime() === day.getTime());
|
||||
const candidates = availabilities.filter(
|
||||
(a) => a.date.getTime() === day.getTime(),
|
||||
);
|
||||
if (!candidates.length) continue;
|
||||
const minUsage = Math.min(...candidates.map((a) => familyUsageCount.get(a.child.familyId) ?? 0));
|
||||
const leastUsed = candidates.filter((a) => (familyUsageCount.get(a.child.familyId) ?? 0) === minUsage);
|
||||
const selected = leastUsed[Math.floor(Math.random() * leastUsed.length)];
|
||||
const minUsage = Math.min(
|
||||
...candidates.map((a) => familyUsageCount.get(a.child.familyId) ?? 0),
|
||||
);
|
||||
const leastUsed = candidates.filter(
|
||||
(a) => (familyUsageCount.get(a.child.familyId) ?? 0) === minUsage,
|
||||
);
|
||||
const selected =
|
||||
leastUsed[Math.floor(Math.random() * leastUsed.length)];
|
||||
assignments.push({ childId: selected.childId, date: day });
|
||||
familyUsageCount.set(selected.child.familyId, minUsage + 1);
|
||||
}
|
||||
const plan = await tx.notdienstPlan.create({
|
||||
data: { kitaId, year: targetYear, month: targetMonth, status: NotdienstPlanStatus.DRAFT, createdById: session.user.id },
|
||||
data: {
|
||||
kitaId,
|
||||
year: targetYear,
|
||||
month: targetMonth,
|
||||
status: NotdienstPlanStatus.DRAFT,
|
||||
createdById: session.user.id,
|
||||
},
|
||||
});
|
||||
if (assignments.length) {
|
||||
await tx.notdienstAssignment.createMany({
|
||||
@@ -91,7 +114,9 @@ export async function generatePlanAction(year?: number, month?: number) {
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { error: publicActionError(error, "Fehler bei der Generierung des Plans.") };
|
||||
return {
|
||||
error: publicActionError(error, "Fehler bei der Generierung des Plans."),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,30 +128,55 @@ export async function publishPlanAction(planId: string) {
|
||||
await serializableTransaction(async (tx) => {
|
||||
const plan = await tx.notdienstPlan.findUnique({
|
||||
where: { id: planId, kitaId },
|
||||
include: { assignments: { include: { child: { select: { active: true } } } } },
|
||||
include: {
|
||||
assignments: { include: { child: { select: { active: true } } } },
|
||||
},
|
||||
});
|
||||
if (!plan) throw new UserFacingError("Plan nicht gefunden.");
|
||||
if (plan.status === NotdienstPlanStatus.PUBLISHED) return;
|
||||
if (plan.status !== NotdienstPlanStatus.DRAFT) throw new UserFacingError("Nur Entwürfe können veröffentlicht werden.");
|
||||
if (plan.status !== NotdienstPlanStatus.DRAFT)
|
||||
throw new UserFacingError("Nur Entwürfe können veröffentlicht werden.");
|
||||
const days = await loadNotdienstDays(tx, kitaId, plan.year, plan.month);
|
||||
const validDates = new Set(days.map((day) => day.getTime()));
|
||||
if (plan.assignments.some((a) => !validDates.has(a.date.getTime()) || !a.child.active)) {
|
||||
throw new UserFacingError("Der Entwurf enthält Einteilungen an geschlossenen Tagen, außerhalb des Planungsmonats oder für inaktive Kinder. Bitte zuerst korrigieren.");
|
||||
if (
|
||||
plan.assignments.some(
|
||||
(a) => !validDates.has(a.date.getTime()) || !a.child.active,
|
||||
)
|
||||
) {
|
||||
throw new UserFacingError(
|
||||
"Der Entwurf enthält Einteilungen an geschlossenen Tagen, außerhalb des Planungsmonats oder für inaktive Kinder. Bitte zuerst korrigieren.",
|
||||
);
|
||||
}
|
||||
await tx.notdienstPlan.update({
|
||||
where: { id: planId, kitaId },
|
||||
data: { status: NotdienstPlanStatus.PUBLISHED, publishedAt: new Date() },
|
||||
data: {
|
||||
status: NotdienstPlanStatus.PUBLISHED,
|
||||
publishedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await queueNotdienstPublication(tx, kitaId, planId, plan.year, plan.month);
|
||||
await queueNotdienstPublication(
|
||||
tx,
|
||||
kitaId,
|
||||
planId,
|
||||
plan.year,
|
||||
plan.month,
|
||||
);
|
||||
});
|
||||
revalidatePath("/dashboard/notdienst/plan");
|
||||
revalidatePath("/dashboard/notdienst");
|
||||
revalidatePath("/dashboard/kalender");
|
||||
const delivery = await deliverDueNotdienstEmails({ kitaId, planId }).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
|
||||
const delivery = await deliverDueNotdienstEmails({ kitaId, planId }).catch(
|
||||
() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }),
|
||||
);
|
||||
revalidatePath("/dashboard/notdienst/plan");
|
||||
return { success: true, ...delivery };
|
||||
} catch (error) {
|
||||
return { error: publicActionError(error, "Der Plan konnte nicht veröffentlicht werden.") };
|
||||
return {
|
||||
error: publicActionError(
|
||||
error,
|
||||
"Der Plan konnte nicht veröffentlicht werden.",
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +188,8 @@ export async function updateAssignmentAction(
|
||||
const actor = await requireAlarmActor();
|
||||
const kitaId = actor.kitaId;
|
||||
|
||||
if (!dateOnlySchema.safeParse(date).success) return { error: "Ungültiges Datum." };
|
||||
if (!dateOnlySchema.safeParse(date).success)
|
||||
return { error: "Ungültiges Datum." };
|
||||
const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
|
||||
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
|
||||
|
||||
@@ -161,14 +212,27 @@ export async function updateAssignmentAction(
|
||||
throw new UserFacingError("Plan kann nicht bearbeitet werden.");
|
||||
}
|
||||
|
||||
if (newChildId && (parsedDate.getUTCFullYear() !== plan.year || parsedDate.getUTCMonth() + 1 !== plan.month)) {
|
||||
throw new UserFacingError("Das Datum gehört nicht zum Planungsmonat.");
|
||||
if (
|
||||
newChildId &&
|
||||
(parsedDate.getUTCFullYear() !== plan.year ||
|
||||
parsedDate.getUTCMonth() + 1 !== plan.month)
|
||||
) {
|
||||
throw new UserFacingError(
|
||||
"Das Datum gehört nicht zum Planungsmonat.",
|
||||
);
|
||||
}
|
||||
// Clearing obsolete entries remains possible after a new closure.
|
||||
if (newChildId) {
|
||||
const days = await loadNotdienstDays(tx, kitaId, plan.year, plan.month);
|
||||
const days = await loadNotdienstDays(
|
||||
tx,
|
||||
kitaId,
|
||||
plan.year,
|
||||
plan.month,
|
||||
);
|
||||
if (!days.some((day) => day.getTime() === parsedDate.getTime())) {
|
||||
throw new UserFacingError("An Wochenenden und Schließtagen kann kein Notdienst eingeteilt werden.");
|
||||
throw new UserFacingError(
|
||||
"An Wochenenden und Schließtagen kann kein Notdienst eingeteilt werden.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +253,18 @@ export async function updateAssignmentAction(
|
||||
family: {
|
||||
include: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
where: {
|
||||
role: {
|
||||
in: [
|
||||
UserRole.ELTERN,
|
||||
UserRole.ADMIN,
|
||||
UserRole.KOORDINATOR,
|
||||
],
|
||||
},
|
||||
emailVerifiedAt: { not: null },
|
||||
disabledAt: null,
|
||||
privacyPolicyAcceptedAt: { not: null },
|
||||
},
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
@@ -217,7 +292,18 @@ export async function updateAssignmentAction(
|
||||
family: {
|
||||
include: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
where: {
|
||||
role: {
|
||||
in: [
|
||||
UserRole.ELTERN,
|
||||
UserRole.ADMIN,
|
||||
UserRole.KOORDINATOR,
|
||||
],
|
||||
},
|
||||
emailVerifiedAt: { not: null },
|
||||
disabledAt: null,
|
||||
privacyPolicyAcceptedAt: { not: null },
|
||||
},
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
@@ -392,7 +478,15 @@ export async function updateKitaNotdienstSettingsAction(
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
if (!Number.isInteger(minPerChild) || minPerChild < 1 || minPerChild > 31 || !Number.isInteger(reminderDays) || reminderDays < 0 || reminderDays > 31) return { error: "Ungültige Einstellungen." };
|
||||
if (
|
||||
!Number.isInteger(minPerChild) ||
|
||||
minPerChild < 1 ||
|
||||
minPerChild > 31 ||
|
||||
!Number.isInteger(reminderDays) ||
|
||||
reminderDays < 0 ||
|
||||
reminderDays > 31
|
||||
)
|
||||
return { error: "Ungültige Einstellungen." };
|
||||
|
||||
try {
|
||||
await prisma.kita.update({
|
||||
@@ -411,33 +505,61 @@ export async function updateKitaNotdienstSettingsAction(
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendNotdienstRemindersAction(year: number, month: number) {
|
||||
export async function sendNotdienstRemindersAction(
|
||||
year: number,
|
||||
month: number,
|
||||
) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId!;
|
||||
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
|
||||
if (!(await rateLimit(`notdienst-reminders:${kitaId}`, 3, 3600)).allowed) return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." };
|
||||
if (!validPlanMonth(year, month))
|
||||
return { error: "Ungültiger Planungsmonat." };
|
||||
if (!(await rateLimit(`notdienst-reminders:${kitaId}`, 3, 3600)).allowed)
|
||||
return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." };
|
||||
try {
|
||||
const queued = await queueNotdienstReminders(kitaId, year, month);
|
||||
const delivery = await deliverDueNotdienstEmails({ kitaId, year, month }).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
|
||||
const delivery = await deliverDueNotdienstEmails({
|
||||
kitaId,
|
||||
year,
|
||||
month,
|
||||
}).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
|
||||
revalidatePath("/dashboard/notdienst/plan");
|
||||
return { success: true, queued, count: delivery.sent, ...delivery };
|
||||
} catch (error) {
|
||||
return { error: publicActionError(error, "Die Erinnerungen konnten nicht vorbereitet werden.") };
|
||||
return {
|
||||
error: publicActionError(
|
||||
error,
|
||||
"Die Erinnerungen konnten nicht vorbereitet werden.",
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function retryNotdienstEmailsAction(year: number, month: number) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId!;
|
||||
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
|
||||
if (!(await rateLimit(`notdienst-mail-retry:${kitaId}`, 3, 3600)).allowed) return { error: "Bitte warte vor einem weiteren Versandversuch." };
|
||||
if (!validPlanMonth(year, month))
|
||||
return { error: "Ungültiger Planungsmonat." };
|
||||
if (!(await rateLimit(`notdienst-mail-retry:${kitaId}`, 3, 3600)).allowed)
|
||||
return { error: "Bitte warte vor einem weiteren Versandversuch." };
|
||||
try {
|
||||
await prisma.notdienstEmail.updateMany({ where: { kitaId, year, month, failed: true, sentAt: null, skippedAt: null }, data: { nextAttemptAt: new Date() } });
|
||||
await prisma.notdienstEmail.updateMany({
|
||||
where: {
|
||||
kitaId,
|
||||
year,
|
||||
month,
|
||||
failed: true,
|
||||
sentAt: null,
|
||||
skippedAt: null,
|
||||
},
|
||||
data: { nextAttemptAt: new Date() },
|
||||
});
|
||||
const delivery = await deliverDueNotdienstEmails({ kitaId, year, month });
|
||||
revalidatePath("/dashboard/notdienst/plan");
|
||||
return { success: true, ...delivery };
|
||||
} catch {
|
||||
return { error: "Der Versand bleibt gespeichert und wird erneut versucht." };
|
||||
return {
|
||||
error: "Der Versand bleibt gespeichert und wird erneut versucht.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@ import { de } from "date-fns/locale";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formatUtcDateKey, getWorkingDaysOfMonth, getTargetMonthData } from "@/lib/date-utils";
|
||||
import {
|
||||
formatUtcDateKey,
|
||||
getWorkingDaysOfMonth,
|
||||
getTargetMonthData,
|
||||
} from "@/lib/date-utils";
|
||||
import { loadNotdienstDays } from "@/lib/notdienst-planning";
|
||||
import { getNotdienstEmailStatus } from "@/lib/notdienst-notifications";
|
||||
import { EmailStatusCard } from "./_components/email-status-card";
|
||||
@@ -108,8 +112,6 @@ export default async function PlanungsZentralePage({
|
||||
]);
|
||||
|
||||
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
|
||||
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
|
||||
// aber für MVP reichen alle aktiven Kinder.
|
||||
const allChildrenRaw = await prisma.child.findMany({
|
||||
where: { kitaId, active: true },
|
||||
select: {
|
||||
@@ -146,8 +148,11 @@ export default async function PlanungsZentralePage({
|
||||
});
|
||||
|
||||
const validDateKeys = new Set(workingDays.map(formatUtcDateKey));
|
||||
const availabilities = rawAvailabilities.filter((a) => validDateKeys.has(formatUtcDateKey(a.date)));
|
||||
const closureCount = getWorkingDaysOfMonth(targetYear, targetMonth).length - workingDays.length;
|
||||
const availabilities = rawAvailabilities.filter((a) =>
|
||||
validDateKeys.has(formatUtcDateKey(a.date)),
|
||||
);
|
||||
const closureCount =
|
||||
getWorkingDaysOfMonth(targetYear, targetMonth).length - workingDays.length;
|
||||
|
||||
const availabilityDatesByFamily = new Map<string, Set<string>>();
|
||||
availabilities.forEach((availability) => {
|
||||
@@ -186,12 +191,16 @@ export default async function PlanungsZentralePage({
|
||||
|
||||
// Wir mappen die Assignments auf die Werktage
|
||||
// Keep obsolete assignments visible so the coordinator can remove them.
|
||||
const displayedDays = new Map(workingDays.map((day) => [formatUtcDateKey(day), day]));
|
||||
const displayedDays = new Map(
|
||||
workingDays.map((day) => [formatUtcDateKey(day), day]),
|
||||
);
|
||||
for (const assignment of plan?.assignments ?? []) {
|
||||
displayedDays.set(formatUtcDateKey(assignment.date), assignment.date);
|
||||
}
|
||||
const activeChildIds = new Set(allChildren.map((child) => child.id));
|
||||
const daysWithAssignments = Array.from(displayedDays.values()).sort((a, b) => a.getTime() - b.getTime()).map((day) => {
|
||||
const daysWithAssignments = Array.from(displayedDays.values())
|
||||
.sort((a, b) => a.getTime() - b.getTime())
|
||||
.map((day) => {
|
||||
const assignment = plan?.assignments.find(
|
||||
(a) => a.date.getTime() === day.getTime(),
|
||||
);
|
||||
@@ -199,7 +208,9 @@ export default async function PlanungsZentralePage({
|
||||
!assignment ||
|
||||
(plan?.publishedAt && assignment.createdAt > plan.publishedAt);
|
||||
|
||||
const isInvalid = !validDateKeys.has(formatUtcDateKey(day)) || !!assignment && !activeChildIds.has(assignment.childId);
|
||||
const isInvalid =
|
||||
!validDateKeys.has(formatUtcDateKey(day)) ||
|
||||
(!!assignment && !activeChildIds.has(assignment.childId));
|
||||
return {
|
||||
date: day.toISOString(),
|
||||
isInvalid,
|
||||
@@ -208,15 +219,24 @@ export default async function PlanungsZentralePage({
|
||||
isOriginallyUnassigned: !!isOriginallyUnassigned,
|
||||
activeAlarmId: assignment?.alarms[0]?.id ?? null,
|
||||
canAlarm:
|
||||
!!assignment && !isInvalid &&
|
||||
!!assignment &&
|
||||
!isInvalid &&
|
||||
plan?.status === "PUBLISHED" &&
|
||||
day.toISOString().slice(0, 10) >= todayKey(),
|
||||
};
|
||||
});
|
||||
|
||||
const mailStatus = await getNotdienstEmailStatus(kitaId, targetYear, targetMonth);
|
||||
const mailStatus = await getNotdienstEmailStatus(
|
||||
kitaId,
|
||||
targetYear,
|
||||
targetMonth,
|
||||
);
|
||||
const target = getTargetMonthData(now);
|
||||
const remindersOpen = !target.isLocked && target.targetYear === targetYear && target.targetMonth === targetMonth && plan?.status !== "PUBLISHED";
|
||||
const remindersOpen =
|
||||
!target.isLocked &&
|
||||
target.targetYear === targetYear &&
|
||||
target.targetMonth === targetMonth &&
|
||||
plan?.status !== "PUBLISHED";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
@@ -261,7 +281,14 @@ export default async function PlanungsZentralePage({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<EmailStatusCard status={mailStatus} year={targetYear} month={targetMonth} remindersOpen={remindersOpen} daysBefore={kita.notdienstReminderDaysBefore} jobConfigured={(process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32} />
|
||||
<EmailStatusCard
|
||||
status={mailStatus}
|
||||
year={targetYear}
|
||||
month={targetMonth}
|
||||
remindersOpen={remindersOpen}
|
||||
daysBefore={kita.notdienstReminderDaysBefore}
|
||||
jobConfigured={(process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32}
|
||||
/>
|
||||
|
||||
<PlanView
|
||||
planId={plan?.id}
|
||||
|
||||
@@ -121,8 +121,14 @@ export function PlanView({
|
||||
if ("error" in result && result.error) {
|
||||
toast.error(result.error as string);
|
||||
} else if ("success" in result && result.success) {
|
||||
if (result.failed || ("deferred" in result && result.deferred)) toast.warning("Plan veröffentlicht. Einige E-Mails stehen noch aus – siehe Versandstatus.");
|
||||
else toast.success("Plan veröffentlicht. Die Benachrichtigungen wurden vorbereitet.");
|
||||
if (result.failed || ("deferred" in result && result.deferred))
|
||||
toast.warning(
|
||||
"Plan veröffentlicht. Einige E-Mails stehen noch aus – siehe Versandstatus.",
|
||||
);
|
||||
else
|
||||
toast.success(
|
||||
"Plan veröffentlicht. Die Benachrichtigungen wurden vorbereitet.",
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -133,8 +139,14 @@ export function PlanView({
|
||||
if ("error" in result && result.error) {
|
||||
toast.error(result.error as string);
|
||||
} else if ("success" in result && result.success) {
|
||||
if (result.failed || ("deferred" in result && result.deferred)) toast.warning(`${result.count} E-Mails versendet. Offene Nachrichten bleiben gespeichert – siehe Versandstatus.`);
|
||||
else toast.success(`${result.count} E-Mails versendet. Bereits heute erinnerte Haushalte erhalten keine zweite Erinnerung.`);
|
||||
if (result.failed || ("deferred" in result && result.deferred))
|
||||
toast.warning(
|
||||
`${result.count} E-Mails versendet. Offene Nachrichten bleiben gespeichert – siehe Versandstatus.`,
|
||||
);
|
||||
else
|
||||
toast.success(
|
||||
`${result.count} E-Mails versendet. Bereits heute erinnerte Haushalte erhalten keine zweite Erinnerung.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -157,7 +169,11 @@ export function PlanView({
|
||||
const valueToSet = newChildId === "empty" ? null : newChildId;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await updateAssignmentAction(planId, date.slice(0, 10), valueToSet);
|
||||
const result = await updateAssignmentAction(
|
||||
planId,
|
||||
date.slice(0, 10),
|
||||
valueToSet,
|
||||
);
|
||||
if ("error" in result && result.error) {
|
||||
toast.error(result.error as string);
|
||||
} else {
|
||||
@@ -248,7 +264,10 @@ export function PlanView({
|
||||
{family.childCount === 1 ? "" : "er"}
|
||||
</p>
|
||||
</div>
|
||||
<FamilyStatusPill selectedDays={family.selectedDays.length} requiredDays={family.requiredDays} />
|
||||
<FamilyStatusPill
|
||||
selectedDays={family.selectedDays.length}
|
||||
requiredDays={family.requiredDays}
|
||||
/>
|
||||
<div className="pointer-events-none absolute right-6 top-10 z-20 hidden max-w-xs rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-xs leading-5 text-white shadow-lg group-hover:block">
|
||||
{family.selectedDays.length > 0
|
||||
? family.selectedDays.map((day) => day.label).join(" · ")
|
||||
@@ -318,8 +337,8 @@ export function PlanView({
|
||||
Plan automatisch generieren
|
||||
</h3>
|
||||
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
||||
Verteilt {requiredDays} Notdiensttage fair auf{" "}
|
||||
die verfügbaren Familien.
|
||||
Verteilt {requiredDays} Notdiensttage fair auf die verfügbaren
|
||||
Familien.
|
||||
</p>
|
||||
<details className="mt-3 text-xs text-[var(--ink-soft)]">
|
||||
<summary className="cursor-pointer font-semibold text-[var(--accent-deep)]">
|
||||
@@ -412,6 +431,7 @@ export function PlanView({
|
||||
<ManualAssignmentTable
|
||||
days={days}
|
||||
allChildren={allChildren}
|
||||
familyStatuses={familyStatuses}
|
||||
isDraft={isDraft}
|
||||
isPending={isPending}
|
||||
onAssignmentChange={handleAssignmentChange}
|
||||
@@ -451,7 +471,13 @@ export function PlanView({
|
||||
);
|
||||
}
|
||||
|
||||
function FamilyStatusPill({ selectedDays, requiredDays }: { selectedDays: number; requiredDays: number }) {
|
||||
function FamilyStatusPill({
|
||||
selectedDays,
|
||||
requiredDays,
|
||||
}: {
|
||||
selectedDays: number;
|
||||
requiredDays: number;
|
||||
}) {
|
||||
if (selectedDays === 0) {
|
||||
return (
|
||||
<span className="rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]">
|
||||
@@ -461,7 +487,13 @@ function FamilyStatusPill({ selectedDays, requiredDays }: { selectedDays: number
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={selectedDays >= requiredDays ? "rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]" : "rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]"}>
|
||||
<span
|
||||
className={
|
||||
selectedDays >= requiredDays
|
||||
? "rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]"
|
||||
: "rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]"
|
||||
}
|
||||
>
|
||||
{selectedDays} / {requiredDays} Tage
|
||||
</span>
|
||||
);
|
||||
@@ -552,7 +584,11 @@ function PlanPreviewTable({
|
||||
{format(dateObj, "EEEE", { locale: de })}
|
||||
</div>
|
||||
<div className="text-[var(--ink)]">
|
||||
{assignedChild ? assignedChild.parentName : day.childId ? "Inaktives Kind" : "Unbesetzt"}
|
||||
{assignedChild
|
||||
? assignedChild.parentName
|
||||
: day.childId
|
||||
? "Inaktives Kind"
|
||||
: "Unbesetzt"}
|
||||
{assignedChild && (
|
||||
<span className="ml-1 text-[var(--ink-muted)]">
|
||||
({assignedChild.name})
|
||||
@@ -576,8 +612,20 @@ function PlanPreviewTable({
|
||||
</div>
|
||||
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
|
||||
<div>
|
||||
<Badge variant={day.isInvalid ? "warning" : assignedChild ? "success" : "destructive"}>
|
||||
{day.isInvalid ? "Bitte korrigieren" : assignedChild ? "Besetzt" : "Offen"}
|
||||
<Badge
|
||||
variant={
|
||||
day.isInvalid
|
||||
? "warning"
|
||||
: assignedChild
|
||||
? "success"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{day.isInvalid
|
||||
? "Bitte korrigieren"
|
||||
: assignedChild
|
||||
? "Besetzt"
|
||||
: "Offen"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -588,17 +636,29 @@ function PlanPreviewTable({
|
||||
);
|
||||
}
|
||||
|
||||
function ManualAssignmentTable({
|
||||
export function ManualAssignmentTable({
|
||||
days,
|
||||
allChildren,
|
||||
familyStatuses,
|
||||
isDraft,
|
||||
isPending,
|
||||
onAssignmentChange,
|
||||
}: Pick<PlanViewProps, "days" | "allChildren"> & {
|
||||
}: Pick<PlanViewProps, "days" | "allChildren" | "familyStatuses"> & {
|
||||
isDraft: boolean;
|
||||
isPending: boolean;
|
||||
onAssignmentChange: (date: string, newChildId: string) => void;
|
||||
}) {
|
||||
const availabilityByFamily = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
familyStatuses.map((family) => [
|
||||
family.id,
|
||||
new Set(family.selectedDays.map((day) => day.iso.slice(0, 10))),
|
||||
]),
|
||||
),
|
||||
[familyStatuses],
|
||||
);
|
||||
|
||||
// Map childId to familyId for quick lookup
|
||||
const childFamilyMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
@@ -626,7 +686,12 @@ function ManualAssignmentTable({
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden p-0">
|
||||
<div className="grid grid-cols-[1fr_2fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
<div className="border-b border-[var(--hairline-soft)] p-4 text-sm leading-6 text-[var(--ink-soft)]">
|
||||
Verfügbare Familien stehen zuerst. Familien ohne Verfügbarkeit für den
|
||||
Tag bleiben für abgestimmte Sonderfälle auswählbar. Die Einteilungszahl
|
||||
gilt für den gesamten Haushalt.
|
||||
</div>
|
||||
<div className="hidden sm:grid grid-cols-[1fr_2fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
<div>Datum</div>
|
||||
<div>Eingeteilte Familie</div>
|
||||
</div>
|
||||
@@ -634,17 +699,41 @@ function ManualAssignmentTable({
|
||||
{days.map((day) => {
|
||||
const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number);
|
||||
const dateObj = new Date(yr, mo - 1, dy);
|
||||
const dateKey = day.date.slice(0, 10);
|
||||
const available = allChildren.filter((child) =>
|
||||
availabilityByFamily.get(child.familyId)?.has(dateKey),
|
||||
);
|
||||
const unavailable = allChildren.filter(
|
||||
(child) => !availabilityByFamily.get(child.familyId)?.has(dateKey),
|
||||
);
|
||||
const availableFamilies = new Set(
|
||||
available.map((child) => child.familyId),
|
||||
).size;
|
||||
const selectedChild = allChildren.find(
|
||||
(child) => child.id === day.childId,
|
||||
);
|
||||
const selectedAvailable =
|
||||
!!selectedChild &&
|
||||
availabilityByFamily.get(selectedChild.familyId)?.has(dateKey);
|
||||
const option = (child: PlanViewProps["allChildren"][number]) => (
|
||||
<option key={child.id} value={child.id} disabled={day.isInvalid}>
|
||||
{child.parentName} ({child.name}) —{" "}
|
||||
{familyAssignmentCounts.get(child.familyId) ?? 0}x
|
||||
</option>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.date}
|
||||
className="grid grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-[var(--surface-2)]"
|
||||
className="grid gap-3 sm:grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
<div className="font-medium text-[var(--ink)]">
|
||||
{format(dateObj, "EE, dd.MM.", { locale: de })}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="w-full max-w-sm space-y-2">
|
||||
<select
|
||||
aria-label={`Familie für ${format(dateObj, "dd.MM.yyyy")}`}
|
||||
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
value={day.childId || "empty"}
|
||||
disabled={isPending || !!day.activeAlarmId}
|
||||
@@ -653,23 +742,39 @@ function ManualAssignmentTable({
|
||||
}
|
||||
>
|
||||
<option value="empty">-- Unbesetzt --</option>
|
||||
{day.childId && !allChildren.some((child) => child.id === day.childId) ? (
|
||||
<option value={day.childId} disabled>Inaktives Kind – bitte Einteilung entfernen</option>
|
||||
) : null}
|
||||
{allChildren.map((child) => {
|
||||
const famId = child.familyId;
|
||||
const count = famId
|
||||
? (familyAssignmentCounts.get(famId) ?? 0)
|
||||
: 0;
|
||||
return (
|
||||
<option key={child.id} value={child.id} disabled={day.isInvalid}>
|
||||
{child.parentName} ({child.name}) — {count}x
|
||||
{day.childId &&
|
||||
!allChildren.some((child) => child.id === day.childId) ? (
|
||||
<option value={day.childId} disabled>
|
||||
Inaktives Kind – bitte Einteilung entfernen
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
) : null}
|
||||
{available.length > 0 ? (
|
||||
<optgroup label="Verfügbar an diesem Tag">
|
||||
{available.map(option)}
|
||||
</optgroup>
|
||||
) : null}
|
||||
{unavailable.length > 0 ? (
|
||||
<optgroup label="Keine Verfügbarkeit für diesen Tag">
|
||||
{unavailable.map(option)}
|
||||
</optgroup>
|
||||
) : null}
|
||||
</select>
|
||||
{!day.isInvalid ? (
|
||||
<p
|
||||
className={`text-xs leading-5 ${(selectedChild && !selectedAvailable) || availableFamilies === 0 ? "text-[var(--warn)]" : "text-[var(--ink-soft)]"}`}
|
||||
>
|
||||
{selectedChild
|
||||
? selectedAvailable
|
||||
? "Eingeteilte Familie ist verfügbar."
|
||||
: "Eingeteilte Familie hat für diesen Tag keine Verfügbarkeit angegeben."
|
||||
: `${availableFamilies} ${availableFamilies === 1 ? "Familie ist" : "Familien sind"} verfügbar.`}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{day.isInvalid ? (
|
||||
<span className="text-xs text-[var(--danger)]">Einteilung nicht mehr gültig – bitte entfernen.</span>
|
||||
<span className="text-xs text-[var(--danger)]">
|
||||
Einteilung nicht mehr gültig – bitte entfernen.
|
||||
</span>
|
||||
) : null}
|
||||
{!isDraft && (
|
||||
<span className="text-[11px] font-semibold text-[oklch(0.60_0.18_45)] bg-[oklch(0.95_0.02_45)] border border-[oklch(0.85_0.05_45)] px-2 py-0.5 rounded-md shrink-0">
|
||||
|
||||
@@ -16,7 +16,11 @@ import { UserRole } from "@prisma/client";
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
import { getTargetMonthData } from "@/lib/date-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { absenceDate, absenceToday, canManageKitaAbsences } from "@/lib/absence-policy";
|
||||
import {
|
||||
absenceDate,
|
||||
absenceToday,
|
||||
canManageKitaAbsences,
|
||||
} from "@/lib/absence-policy";
|
||||
import { Stat } from "@/components/ui/stat";
|
||||
import { OnboardingDialog } from "@/components/OnboardingDialog";
|
||||
import { NewsTicker } from "./news-ticker";
|
||||
@@ -174,6 +178,7 @@ export default async function DashboardPage() {
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<OnboardingDialog
|
||||
user={{
|
||||
id: session.user.id,
|
||||
phone: onboardingUser.phone,
|
||||
street: onboardingUser.street,
|
||||
postalCode: onboardingUser.postalCode,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useId, useState, useTransition, type ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -32,6 +33,7 @@ export function Field({
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
autoFocus,
|
||||
}: {
|
||||
label: string;
|
||||
name: string;
|
||||
@@ -41,6 +43,7 @@ export function Field({
|
||||
min?: string | number;
|
||||
max?: string | number;
|
||||
step?: string | number;
|
||||
autoFocus?: boolean;
|
||||
}) {
|
||||
const id = useId();
|
||||
return (
|
||||
@@ -57,6 +60,7 @@ export function Field({
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -123,7 +127,11 @@ function transform(command: string, form: FormData) {
|
||||
return {
|
||||
kind: text("kind"),
|
||||
startDate: text("startDate"),
|
||||
endDate: text("endDate"),
|
||||
endDate:
|
||||
text("kind") === "TIME_OFF" ||
|
||||
(text("kind") === "VACATION" && form.get("halfDay") === "on")
|
||||
? text("startDate")
|
||||
: text("endDate"),
|
||||
halfDay: form.get("halfDay") === "on",
|
||||
...(text("kind") === "TIME_OFF" ? { minutes: hours("hours") } : {}),
|
||||
note: text("note"),
|
||||
@@ -142,13 +150,19 @@ function StaffForm({
|
||||
children,
|
||||
label = "Speichern",
|
||||
onSuccess,
|
||||
anotherLabel,
|
||||
}: {
|
||||
command: string;
|
||||
profileId?: string;
|
||||
extra?: Record<string, unknown>;
|
||||
children: ReactNode;
|
||||
label?: string;
|
||||
onSuccess?: (result: StaffActionResult) => void;
|
||||
anotherLabel?: string;
|
||||
onSuccess?: (
|
||||
result: StaffActionResult,
|
||||
form: FormData,
|
||||
another: boolean,
|
||||
) => void;
|
||||
}) {
|
||||
const [pending, start] = useTransition();
|
||||
const [error, setError] = useState("");
|
||||
@@ -159,6 +173,10 @@ function StaffForm({
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const another =
|
||||
(event.nativeEvent as SubmitEvent).submitter?.getAttribute(
|
||||
"value",
|
||||
) === "another";
|
||||
setError("");
|
||||
start(async () => {
|
||||
try {
|
||||
@@ -173,7 +191,7 @@ function StaffForm({
|
||||
}
|
||||
if (result.warning) toast.warning(result.warning);
|
||||
else toast.success(result.message ?? "Gespeichert.");
|
||||
onSuccess?.(result);
|
||||
onSuccess?.(result, form, another);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setError(
|
||||
@@ -187,10 +205,20 @@ function StaffForm({
|
||||
>
|
||||
<fieldset disabled={pending} className="grid gap-4 disabled:opacity-60">
|
||||
{children}
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit">
|
||||
{pending ? "Wird gespeichert …" : label}
|
||||
</Button>
|
||||
{anotherLabel ? (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
name="intent"
|
||||
value="another"
|
||||
>
|
||||
{anotherLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</fieldset>
|
||||
{error && (
|
||||
@@ -333,8 +361,12 @@ export function SetupForm({
|
||||
export function TimeForm({
|
||||
profileId,
|
||||
initial,
|
||||
defaultDate,
|
||||
compact = false,
|
||||
}: {
|
||||
profileId: string;
|
||||
defaultDate?: string;
|
||||
compact?: boolean;
|
||||
initial?: {
|
||||
id: string;
|
||||
date: string;
|
||||
@@ -345,14 +377,34 @@ export function TimeForm({
|
||||
};
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [date, setDate] = useState(initial?.date ?? defaultDate ?? todayKey());
|
||||
const [blockVersion, setBlockVersion] = useState(0);
|
||||
const [continuation, setContinuation] = useState(false);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (next) {
|
||||
setDate(initial?.date ?? defaultDate ?? todayKey());
|
||||
setContinuation(false);
|
||||
}
|
||||
setOpen(next);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant={initial ? "outline" : "default"}
|
||||
size={initial ? "sm" : "default"}
|
||||
variant={initial || compact ? "outline" : "default"}
|
||||
size={initial || compact ? "sm" : "default"}
|
||||
disabled={!initial && !!defaultDate && defaultDate > todayKey()}
|
||||
aria-label={
|
||||
compact ? `Arbeitszeit für ${defaultDate} erfassen` : undefined
|
||||
}
|
||||
>
|
||||
{initial ? "Bearbeiten" : "+ Arbeitszeit erfassen"}
|
||||
{initial
|
||||
? "Bearbeiten"
|
||||
: compact
|
||||
? "Zeit erfassen"
|
||||
: "+ Arbeitszeit erfassen"}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
@@ -366,31 +418,50 @@ export function TimeForm({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<StaffForm
|
||||
key={blockVersion}
|
||||
command="time"
|
||||
profileId={profileId}
|
||||
extra={initial ? { id: initial.id } : {}}
|
||||
label="Arbeitszeit speichern"
|
||||
onSuccess={() => setOpen(false)}
|
||||
anotherLabel={
|
||||
!initial ? "Speichern und weiteren Block erfassen" : undefined
|
||||
}
|
||||
onSuccess={(_result, form, another) => {
|
||||
if (another && !initial) {
|
||||
setDate(String(form.get("date")));
|
||||
setContinuation(true);
|
||||
setBlockVersion((version) => version + 1);
|
||||
} else setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
label="Datum"
|
||||
name="date"
|
||||
type="date"
|
||||
value={initial?.date ?? todayKey()}
|
||||
value={date}
|
||||
max={todayKey()}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
label="Beginn"
|
||||
autoFocus={continuation}
|
||||
name="start"
|
||||
type="time"
|
||||
value={minuteClock(initial?.startMinute ?? 480)}
|
||||
value={
|
||||
continuation
|
||||
? undefined
|
||||
: minuteClock(initial?.startMinute ?? 480)
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label="Ende"
|
||||
name="end"
|
||||
type="time"
|
||||
value={minuteClock(initial?.endMinute ?? 960)}
|
||||
value={
|
||||
continuation
|
||||
? undefined
|
||||
: minuteClock(initial?.endMinute ?? 960)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
@@ -399,7 +470,7 @@ export function TimeForm({
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={initial?.breakMinutes ?? 30}
|
||||
value={continuation ? 0 : (initial?.breakMinutes ?? 30)}
|
||||
/>
|
||||
<Field
|
||||
label="Notiz (optional)"
|
||||
@@ -431,9 +502,24 @@ export function LeaveForm({
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [kind, setKind] = useState(initial?.kind ?? "VACATION");
|
||||
const [startDate, setStartDate] = useState(initial?.startDate ?? todayKey());
|
||||
const [endDate, setEndDate] = useState(initial?.endDate ?? todayKey());
|
||||
const [halfDay, setHalfDay] = useState(initial?.halfDay ?? false);
|
||||
const singleDay = kind === "TIME_OFF" || (kind === "VACATION" && halfDay);
|
||||
const id = useId();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (next) {
|
||||
setKind(initial?.kind ?? "VACATION");
|
||||
setStartDate(initial?.startDate ?? todayKey());
|
||||
setEndDate(initial?.endDate ?? todayKey());
|
||||
setHalfDay(initial?.halfDay ?? false);
|
||||
}
|
||||
setOpen(next);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button size={initial ? "sm" : "default"} variant="outline">
|
||||
{initial ? "Ändern" : "+ Abwesenheit beantragen"}
|
||||
@@ -474,33 +560,51 @@ export function LeaveForm({
|
||||
{admin && <option value="SICKNESS">Krankheit</option>}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
label="Von"
|
||||
name="startDate"
|
||||
type="date"
|
||||
value={initial?.startDate ?? todayKey()}
|
||||
/>
|
||||
<Field
|
||||
label="Bis"
|
||||
name="endDate"
|
||||
type="date"
|
||||
value={initial?.endDate ?? todayKey()}
|
||||
/>
|
||||
</div>
|
||||
{kind === "VACATION" && (
|
||||
{kind === "VACATION" ? (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
name="halfDay"
|
||||
type="checkbox"
|
||||
defaultChecked={initial?.halfDay}
|
||||
/>
|
||||
Halber Urlaubstag (Von = Bis)
|
||||
checked={halfDay}
|
||||
onChange={(event) => setHalfDay(event.target.checked)}
|
||||
/>{" "}
|
||||
Halber Urlaubstag
|
||||
</label>
|
||||
) : null}
|
||||
<div className={singleDay ? "grid gap-4" : "grid grid-cols-2 gap-4"}>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={`${id}-start`}>
|
||||
{singleDay ? "Datum" : "Von"}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${id}-start`}
|
||||
name="startDate"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(event) => setStartDate(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{singleDay ? (
|
||||
<input type="hidden" name="endDate" value={startDate} />
|
||||
) : (
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={`${id}-end`}>Bis</Label>
|
||||
<Input
|
||||
id={`${id}-end`}
|
||||
name="endDate"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(event) => setEndDate(event.target.value)}
|
||||
required
|
||||
min={startDate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{kind === "TIME_OFF" && (
|
||||
<Field
|
||||
label="Freizeitausgleich in Stunden (Von = Bis)"
|
||||
label="Freizeitausgleich in Stunden"
|
||||
name="hours"
|
||||
type="number"
|
||||
min={1 / 60}
|
||||
|
||||
@@ -284,7 +284,17 @@ export default async function PersonalPage({
|
||||
</div>
|
||||
{profile && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TimeForm profileId={profile.id} />
|
||||
<TimeForm
|
||||
key={`${profile.id}-${start}`}
|
||||
profileId={profile.id}
|
||||
defaultDate={
|
||||
tab === "zeit"
|
||||
? today >= start && today <= end
|
||||
? today
|
||||
: start
|
||||
: today
|
||||
}
|
||||
/>
|
||||
<LeaveForm profileId={profile.id} admin={admin} />
|
||||
</div>
|
||||
)}
|
||||
@@ -645,6 +655,7 @@ export default async function PersonalPage({
|
||||
"Ist",
|
||||
"Urlaub / Krankheit",
|
||||
"Differenz",
|
||||
"Erfassen",
|
||||
].map((s) => (
|
||||
<th key={s} className="pb-3 pr-4 font-medium">
|
||||
{s}
|
||||
@@ -682,6 +693,15 @@ export default async function PersonalPage({
|
||||
>
|
||||
{formatMinutes(d.balance)}
|
||||
</td>
|
||||
<td className="py-3 pl-3">
|
||||
{editable && d.date <= today ? (
|
||||
<TimeForm
|
||||
profileId={profile.id}
|
||||
defaultDate={d.date}
|
||||
compact
|
||||
/>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -696,6 +716,7 @@ export default async function PersonalPage({
|
||||
<td className="pt-4">
|
||||
{formatMinutes(totals.balance)}
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
parseDateInput,
|
||||
} from "@/lib/child-birthdate";
|
||||
import { serializableTransaction } from "@/lib/transaction";
|
||||
import {
|
||||
assertKitaAdminRemains,
|
||||
lockKitaAccounts,
|
||||
} from "@/lib/kita-admin-guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { assertChildrenRemovable } from "@/lib/notdienst-planning";
|
||||
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||
@@ -59,7 +63,11 @@ const childSchema = z
|
||||
});
|
||||
|
||||
const familySchema = z.object({
|
||||
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
|
||||
familyName: z
|
||||
.string()
|
||||
.min(1, "Familienname ist erforderlich.")
|
||||
.max(120)
|
||||
.trim(),
|
||||
});
|
||||
|
||||
const contactSchema = z.object({
|
||||
@@ -88,10 +96,7 @@ const contactSchema = z.object({
|
||||
|
||||
// Gibt die erste konkrete Field-Error-Message aus einem Zod-Fehler zurück,
|
||||
// damit der User nicht nur "Ungültige Eingabedaten" sieht.
|
||||
function firstZodMessage(
|
||||
error: z.ZodError,
|
||||
fallback: string,
|
||||
): string {
|
||||
function firstZodMessage(error: z.ZodError, fallback: string): string {
|
||||
const issue = error.issues[0];
|
||||
return issue?.message || fallback;
|
||||
}
|
||||
@@ -121,8 +126,13 @@ export async function createMyChild(rawPayload: unknown) {
|
||||
}
|
||||
|
||||
try {
|
||||
await serializableTransaction(async tx => {
|
||||
const count = await tx.child.count({ where: { kitaId: session.user.kitaId, familyId: session.user.familyId! } });
|
||||
await serializableTransaction(async (tx) => {
|
||||
const count = await tx.child.count({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
familyId: session.user.familyId!,
|
||||
},
|
||||
});
|
||||
if (count >= 20) throw new Error("Household child limit reached");
|
||||
await tx.child.create({
|
||||
data: {
|
||||
@@ -166,10 +176,17 @@ export async function createMyFamily(rawPayload: unknown) {
|
||||
});
|
||||
|
||||
const attached = await tx.user.updateMany({
|
||||
where: { id: session.user.id, kitaId: session.user.kitaId, familyId: null, disabledAt: null, sessionVersion: session.user.sessionVersion },
|
||||
where: {
|
||||
id: session.user.id,
|
||||
kitaId: session.user.kitaId,
|
||||
familyId: null,
|
||||
disabledAt: null,
|
||||
sessionVersion: session.user.sessionVersion,
|
||||
},
|
||||
data: { familyId: family.id },
|
||||
});
|
||||
if (attached.count !== 1) throw new Error("Account already assigned or revoked");
|
||||
if (attached.count !== 1)
|
||||
throw new Error("Account already assigned or revoked");
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/profil");
|
||||
@@ -178,7 +195,12 @@ export async function createMyFamily(rawPayload: unknown) {
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && error.message === "Account already assigned or revoked")) {
|
||||
if (
|
||||
!(
|
||||
error instanceof Error &&
|
||||
error.message === "Account already assigned or revoked"
|
||||
)
|
||||
) {
|
||||
console.error("Fehler beim Anlegen des eigenen Haushalts:", error);
|
||||
}
|
||||
return { error: "Der Haushalt konnte nicht angelegt werden." };
|
||||
@@ -249,7 +271,11 @@ export async function updateMyChild(childId: string, rawPayload: unknown) {
|
||||
}
|
||||
|
||||
await prisma.child.update({
|
||||
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId! },
|
||||
where: {
|
||||
id: childId,
|
||||
kitaId: session.user.kitaId,
|
||||
familyId: session.user.familyId!,
|
||||
},
|
||||
data: {
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
@@ -271,15 +297,34 @@ export async function deleteMyChild(childId: string) {
|
||||
|
||||
try {
|
||||
await serializableTransaction(async (tx) => {
|
||||
if (!session.user.familyId) throw new UserFacingError("Dieses Kind gehört nicht zu deinem Haushalt.");
|
||||
if (!session.user.familyId)
|
||||
throw new UserFacingError(
|
||||
"Dieses Kind gehört nicht zu deinem Haushalt.",
|
||||
);
|
||||
const child = await tx.child.findFirst({
|
||||
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId },
|
||||
where: {
|
||||
id: childId,
|
||||
kitaId: session.user.kitaId,
|
||||
familyId: session.user.familyId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!child) throw new UserFacingError("Dieses Kind gehört nicht zu deinem Haushalt.");
|
||||
await assertChildrenRemovable(tx, session.user.kitaId, session.user.familyId, [childId]);
|
||||
if (!child)
|
||||
throw new UserFacingError(
|
||||
"Dieses Kind gehört nicht zu deinem Haushalt.",
|
||||
);
|
||||
await assertChildrenRemovable(
|
||||
tx,
|
||||
session.user.kitaId,
|
||||
session.user.familyId,
|
||||
[childId],
|
||||
);
|
||||
await tx.child.delete({
|
||||
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId },
|
||||
where: {
|
||||
id: childId,
|
||||
kitaId: session.user.kitaId,
|
||||
familyId: session.user.familyId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -287,35 +332,32 @@ export async function deleteMyChild(childId: string) {
|
||||
revalidatePath("/dashboard/families");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (!(error instanceof UserFacingError)) console.error("Fehler beim Entfernen des Kindes:", error);
|
||||
return { error: publicActionError(error, "Das Kind konnte nicht entfernt werden.") };
|
||||
if (!(error instanceof UserFacingError))
|
||||
console.error("Fehler beim Entfernen des Kindes:", error);
|
||||
return {
|
||||
error: publicActionError(error, "Das Kind konnte nicht entfernt werden."),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMyAccount() {
|
||||
export async function deleteMyAccount(): Promise<{
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const session = await requireKitaSession();
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
kitaId: session.user.kitaId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
familyId: true,
|
||||
},
|
||||
return await serializableTransaction(async (tx) => {
|
||||
await lockKitaAccounts(tx, session.user.kitaId);
|
||||
const user = await tx.user.findFirst({
|
||||
where: { id: session.user.id, kitaId: session.user.kitaId },
|
||||
select: { id: true, familyId: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return { error: "Account wurde nicht gefunden." };
|
||||
}
|
||||
|
||||
if (!user) throw new UserFacingError("Account wurde nicht gefunden.");
|
||||
await assertKitaAdminRemains(tx, session.user.kitaId, [user.id]);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const activeNotdienstAssignments = user.familyId
|
||||
? await prisma.notdienstAssignment.count({
|
||||
const assignments = user.familyId
|
||||
? await tx.notdienstAssignment.count({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
date: { gte: today },
|
||||
@@ -324,52 +366,33 @@ export async function deleteMyAccount() {
|
||||
},
|
||||
})
|
||||
: 0;
|
||||
|
||||
if (activeNotdienstAssignments > 0) {
|
||||
return {
|
||||
error:
|
||||
if (assignments)
|
||||
throw new UserFacingError(
|
||||
"Dein Account kann nicht gelöscht werden, solange aktive Notdienst-Verpflichtungen bestehen.",
|
||||
);
|
||||
if (user.familyId) {
|
||||
const family = await tx.family.findFirst({
|
||||
where: { id: user.familyId, kitaId: session.user.kitaId },
|
||||
select: { id: true, users: { select: { id: true } } },
|
||||
});
|
||||
if (!family)
|
||||
throw new UserFacingError("Haushalt wurde nicht gefunden.");
|
||||
if (family.users.length <= 1) {
|
||||
await tx.family.delete({ where: { id: family.id } });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
await tx.user.delete({ where: { id: user.id } });
|
||||
return { success: true };
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof UserFacingError))
|
||||
console.error("Fehler beim Löschen des Accounts:", error);
|
||||
return {
|
||||
error: publicActionError(
|
||||
error,
|
||||
"Ein Fehler ist beim Löschen aufgetreten.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!user.familyId) {
|
||||
await prisma.user.delete({
|
||||
where: { id: user.id },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const family = await prisma.family.findFirst({
|
||||
where: {
|
||||
id: user.familyId,
|
||||
kitaId: session.user.kitaId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
users: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!family) {
|
||||
return { error: "Haushalt wurde nicht gefunden." };
|
||||
}
|
||||
|
||||
if (family.users.length <= 1) {
|
||||
await prisma.family.delete({
|
||||
where: { id: family.id },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
await prisma.user.delete({
|
||||
where: { id: user.id },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Löschen des Accounts:", error);
|
||||
return { error: "Ein Fehler ist beim Löschen aufgetreten." };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { DeleteAccountDialog } from "./_components/delete-account-dialog";
|
||||
import { MyChildrenManager } from "./_components/my-children-manager";
|
||||
import { CreateFamilyDialog } from "./_components/create-family-dialog";
|
||||
import { ContactForm } from "./_components/contact-form";
|
||||
import { PasswordForm } from "../../passwort-vergessen/password-form";
|
||||
import { StaffEmailSettings } from "../personal/forms";
|
||||
|
||||
export const metadata = { title: "Mein Profil · Kita-Planer" };
|
||||
@@ -56,6 +57,7 @@ export default async function ProfilPage() {
|
||||
city: true,
|
||||
directoryOptInAt: true,
|
||||
staffDecisionEmails: true,
|
||||
staffProfile: { select: { id: true } },
|
||||
createdAt: true,
|
||||
family: {
|
||||
select: {
|
||||
@@ -210,7 +212,19 @@ export default async function ProfilPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{user.role === UserRole.ERZIEHER && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Passwort ändern</CardTitle>
|
||||
<CardDescription>
|
||||
Verwalte dein persönliches Anmeldepasswort.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PasswordForm mode="change" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{user.staffProfile && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>E-Mail-Hinweise</CardTitle>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { UserRole } from "@prisma/client";
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { changeKitaRole } from "@/lib/kita-role-management";
|
||||
import { publicActionError, UserFacingError } from "@/lib/action-error";
|
||||
|
||||
export async function changeKitaRoleAction(
|
||||
raw: unknown,
|
||||
): Promise<{
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
changed?: boolean;
|
||||
ownRoleChanged?: boolean;
|
||||
}> {
|
||||
const { user } = await requireRole([UserRole.ADMIN]);
|
||||
if (!user.kitaId) return { error: "Keine Kita zugeordnet." };
|
||||
try {
|
||||
const result = await changeKitaRole(
|
||||
{ id: user.id, kitaId: user.kitaId, sessionVersion: user.sessionVersion },
|
||||
raw,
|
||||
);
|
||||
revalidatePath("/dashboard", "layout");
|
||||
revalidatePath("/admin");
|
||||
return { success: true, ...result };
|
||||
} catch (error) {
|
||||
if (!(error instanceof UserFacingError))
|
||||
console.error("Rolle konnte nicht geändert werden:", error);
|
||||
return {
|
||||
error: publicActionError(
|
||||
error,
|
||||
"Rolle konnte nicht geändert werden. Bitte erneut versuchen.",
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { UserRole } from "@prisma/client";
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getKitaRoleHistory } from "@/lib/kita-role-management";
|
||||
import { kitaRoleLabels } from "@/lib/kita-roles";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { RoleList } from "./role-list";
|
||||
|
||||
export const metadata = { title: "Zugriffsrechte · Kita-Planer" };
|
||||
export default async function KitaRolesPage() {
|
||||
const { user } = await requireRole([UserRole.ADMIN]);
|
||||
const kitaId = user.kitaId!;
|
||||
const [people, history] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where: { kitaId, role: { not: UserRole.SUPERADMIN } },
|
||||
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
role: true,
|
||||
familyId: true,
|
||||
disabledAt: true,
|
||||
lockedUntil: true,
|
||||
emailVerifiedAt: true,
|
||||
family: { select: { name: true } },
|
||||
staffProfile: { select: { id: true } },
|
||||
},
|
||||
}),
|
||||
getKitaRoleHistory(kitaId),
|
||||
]);
|
||||
const now = new Date();
|
||||
return (
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<header className="mb-7">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-deep)]">
|
||||
VERWALTUNG · KITA-KONTEN
|
||||
</p>
|
||||
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||
Zugriffsrechte{" "}
|
||||
<em className="font-normal text-[var(--accent-deep)]">verwalten</em>
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Ernenne weitere Kita-Admins oder die Notdienst-Koordination. Rollen
|
||||
gelten sofort; offene Einladungen müssen weiterhin aktiviert werden.
|
||||
Mindestens ein aktiver Kita-Admin bleibt erhalten.
|
||||
</p>
|
||||
</header>
|
||||
<RoleList
|
||||
currentUserId={user.id}
|
||||
people={people.map((person) => ({
|
||||
id: person.id,
|
||||
name: `${person.firstName} ${person.lastName}`,
|
||||
email: person.email,
|
||||
role: person.role,
|
||||
familyId: person.familyId,
|
||||
familyName: person.family?.name ?? null,
|
||||
hasStaffProfile: !!person.staffProfile,
|
||||
status: person.disabledAt
|
||||
? "Konto deaktiviert"
|
||||
: person.lockedUntil && person.lockedUntil > now
|
||||
? "Anmeldung gesperrt"
|
||||
: !person.emailVerifiedAt
|
||||
? "Einladung offen"
|
||||
: "Zugang aktiviert",
|
||||
}))}
|
||||
/>
|
||||
<Card className="mt-7 p-5">
|
||||
<h2 className="text-lg font-semibold text-[var(--ink)]">
|
||||
Letzte Rollenänderungen
|
||||
</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-[var(--ink-soft)]">
|
||||
Ämter und Zuständigkeiten verwaltest du weiterhin bei den Familien.
|
||||
Sie verändern die Zugriffsrechte nicht.
|
||||
</p>
|
||||
{history.length ? (
|
||||
<ul className="mt-4 divide-y divide-[var(--hairline-soft)]">
|
||||
{history.map((entry) => {
|
||||
const data = entry.metadata as {
|
||||
previousRole?: UserRole;
|
||||
newRole?: UserRole;
|
||||
} | null;
|
||||
return (
|
||||
<li key={entry.id} className="py-3 text-sm leading-6">
|
||||
<p className="font-medium text-[var(--ink)]">
|
||||
{entry.targetLabel}:{" "}
|
||||
{data?.previousRole
|
||||
? kitaRoleLabels[data.previousRole]
|
||||
: "—"}{" "}
|
||||
→ {data?.newRole ? kitaRoleLabels[data.newRole] : "—"}
|
||||
</p>
|
||||
<p className="break-words text-xs text-[var(--ink-muted)]">
|
||||
{new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
timeZone: "Europe/Berlin",
|
||||
}).format(entry.createdAt)}{" "}
|
||||
· {entry.actorEmail}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-4 text-sm text-[var(--ink-muted)]">
|
||||
Noch keine Rollenänderungen protokolliert.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { UserRole } from "@prisma/client";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { availableKitaRoles, kitaRoleLabels } from "@/lib/kita-roles";
|
||||
import { changeKitaRoleAction } from "./actions";
|
||||
|
||||
const descriptions: Record<string, string> = {
|
||||
ELTERN: "Eigener Haushalt, Verfügbarkeiten, Diensttausch und Kita-Alltag.",
|
||||
KOORDINATOR: "Zusätzlich Notdienstpläne, Alarme und Termine koordinieren.",
|
||||
ADMIN:
|
||||
"Familien, Team, Zugriffsrechte und alle Kita-Verwaltungsbereiche verwalten.",
|
||||
ERZIEHER:
|
||||
"Personalbereich, eigene Arbeitszeit und Anträge sowie Kinder-Abwesenheiten.",
|
||||
};
|
||||
|
||||
export type RolePerson = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
familyId: string | null;
|
||||
hasStaffProfile: boolean;
|
||||
};
|
||||
|
||||
export function RoleChangeButton({
|
||||
person,
|
||||
ownAccount = false,
|
||||
}: {
|
||||
person: RolePerson;
|
||||
ownAccount?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [role, setRole] = useState(person.role);
|
||||
const [error, setError] = useState("");
|
||||
const [pending, startTransition] = useTransition();
|
||||
const roles = availableKitaRoles(person);
|
||||
const submit = () => {
|
||||
setError("");
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await changeKitaRoleAction({
|
||||
userId: person.id,
|
||||
role,
|
||||
expectedRole: person.role,
|
||||
});
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success("Zugriffsrolle gespeichert.");
|
||||
setOpen(false);
|
||||
if (result.ownRoleChanged) router.replace("/dashboard");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError(
|
||||
"Die Änderung konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={`Zugriffsrechte für ${person.name}`}
|
||||
disabled={person.role === "SUPERADMIN"}
|
||||
onClick={() => {
|
||||
setRole(person.role);
|
||||
setError("");
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<ShieldCheck className="h-3.5 w-3.5" /> Zugriffsrechte
|
||||
</Button>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!pending) setOpen(next);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Zugriffsrechte für {person.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Die Zugriffsrolle bestimmt, welche Bereiche diese Person nutzen
|
||||
und verwalten kann. Ämter wie „Vorstand“ werden separat vergeben.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<fieldset disabled={pending} className="grid gap-2">
|
||||
<legend className="mb-2 text-sm font-semibold text-[var(--ink)]">
|
||||
Zugriffsrolle
|
||||
</legend>
|
||||
{roles.map((value) => (
|
||||
<label
|
||||
key={value}
|
||||
className={`flex cursor-pointer items-start gap-3 rounded-xl border p-3 transition-colors ${role === value ? "border-[var(--accent-mid)] bg-[var(--accent-soft)]" : "border-[var(--hairline)] hover:bg-[var(--surface-2)]"}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`role-${person.id}`}
|
||||
value={value}
|
||||
checked={role === value}
|
||||
onChange={() => setRole(value)}
|
||||
className="mt-1 accent-[var(--accent-deep)]"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold text-[var(--ink)]">
|
||||
{kitaRoleLabels[value]}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-[var(--ink-soft)]">
|
||||
{descriptions[value]}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<p className="text-xs leading-5 text-[var(--ink-soft)]">
|
||||
{person.hasStaffProfile
|
||||
? "Das Personalprofil mit Arbeitszeiten und Anträgen bleibt erhalten. Mit Kita-Admin-Rechten ist zusätzlich die Personalverwaltung zugänglich."
|
||||
: person.familyId
|
||||
? "Haushalt, Kinder und eigene Dienste bleiben erhalten."
|
||||
: "Dieses Konto hat keinen eigenen Haushalt. Deshalb steht die Elternrolle nicht zur Auswahl."}
|
||||
</p>
|
||||
{ownAccount && role !== person.role ? (
|
||||
<p className="rounded-lg bg-[var(--warn-soft)] p-3 text-sm leading-5 text-[var(--warn)]">
|
||||
Du änderst deine eigenen Rechte. Nach dem Speichern gelangst du
|
||||
zur Übersicht; der Zugang zur Verwaltung richtet sich dann nach
|
||||
der neuen Rolle.
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm leading-5 text-[var(--danger)]">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button disabled={pending || role === person.role} onClick={submit}>
|
||||
{pending ? "Wird gespeichert …" : "Rolle speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { RoleBadge } from "@/components/ui/role-badge";
|
||||
import { kitaRoleLabels } from "@/lib/kita-roles";
|
||||
import { RoleChangeButton, type RolePerson } from "./role-change-button";
|
||||
|
||||
type Person = RolePerson & {
|
||||
email: string;
|
||||
familyName: string | null;
|
||||
status: string;
|
||||
};
|
||||
export function RoleList({
|
||||
people,
|
||||
currentUserId,
|
||||
}: {
|
||||
people: Person[];
|
||||
currentUserId: string;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const search = query.trim().toLocaleLowerCase("de");
|
||||
const filtered = people.filter((person) =>
|
||||
`${person.name} ${person.email} ${person.familyName ?? ""} ${kitaRoleLabels[person.role]}`
|
||||
.toLocaleLowerCase("de")
|
||||
.includes(search),
|
||||
);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Input
|
||||
type="search"
|
||||
aria-label="Kita-Konten durchsuchen"
|
||||
placeholder="Name, Haushalt oder Rolle suchen …"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="max-w-md"
|
||||
/>
|
||||
<span className="text-sm text-[var(--ink-muted)]">
|
||||
{filtered.length} {filtered.length === 1 ? "Konto" : "Konten"}
|
||||
</span>
|
||||
</div>
|
||||
<Card className="overflow-hidden p-0">
|
||||
<div className="hidden gap-6 border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] px-5 py-3 text-[11px] font-semibold uppercase tracking-wider text-[var(--ink-muted)] md:grid md:grid-cols-[minmax(0,1fr)_220px_160px]">
|
||||
<span>Person & Zuordnung</span>
|
||||
<span>Zugriffsrolle</span>
|
||||
<span className="text-right">Verwalten</span>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--hairline-soft)]">
|
||||
{filtered.map((person) => (
|
||||
<div
|
||||
key={person.id}
|
||||
className="grid items-center gap-4 px-5 py-5 md:grid-cols-[minmax(0,1fr)_220px_160px] md:gap-6"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-[var(--ink)]">
|
||||
{person.name}
|
||||
{person.id === currentUserId ? (
|
||||
<span className="ml-2 text-xs font-normal text-[var(--ink-muted)]">
|
||||
Du
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="mt-1 break-words text-sm text-[var(--ink-soft)]">
|
||||
{person.email}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
|
||||
{[
|
||||
person.familyName
|
||||
? `Haushalt ${person.familyName}`
|
||||
: "Kein eigener Haushalt",
|
||||
person.hasStaffProfile ? "Personalprofil vorhanden" : null,
|
||||
person.status,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
<RoleBadge role={person.role} />
|
||||
<div className="md:justify-self-end">
|
||||
<RoleChangeButton
|
||||
person={person}
|
||||
ownAccount={person.id === currentUserId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!filtered.length ? (
|
||||
<p className="p-8 text-center text-sm text-[var(--ink-soft)]">
|
||||
Keine passenden Kita-Konten gefunden.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -23,7 +24,10 @@ export function LoginForm({
|
||||
devParentEmail?: string;
|
||||
devParentPassword?: string;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState(loginAction, initialState);
|
||||
const [state, formAction, pending] = useActionState(
|
||||
loginAction,
|
||||
initialState,
|
||||
);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
@@ -67,6 +71,13 @@ export function LoginForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/passwort-vergessen"
|
||||
className="inline-block text-sm text-[var(--accent-deep)] underline underline-offset-4"
|
||||
>
|
||||
Passwort vergessen?
|
||||
</Link>
|
||||
|
||||
{state.errors?._form?.[0] && (
|
||||
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{state.errors._form[0]}
|
||||
@@ -79,7 +90,9 @@ export function LoginForm({
|
||||
|
||||
{showDevHelpers && (
|
||||
<div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/40 p-3 space-y-2 text-xs">
|
||||
<p className="text-center font-medium text-muted-foreground">Dev-Schnellzugriff (nur lokal):</p>
|
||||
<p className="text-center font-medium text-muted-foreground">
|
||||
Dev-Schnellzugriff (nur lokal):
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{devAdminEmail && (
|
||||
<Button
|
||||
|
||||
+24
-4
@@ -17,7 +17,11 @@ import { LoginForm } from "./login-form";
|
||||
export const metadata = { title: "Anmelden · Kita-Planer" };
|
||||
|
||||
type LoginPageProps = {
|
||||
searchParams?: Promise<{ callbackUrl?: string | string[]; reauth?: string }>;
|
||||
searchParams?: Promise<{
|
||||
callbackUrl?: string | string[];
|
||||
reauth?: string;
|
||||
passwordChanged?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
@@ -31,9 +35,13 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const devAdminEmail = isDev ? process.env.DEV_LOGIN_ADMIN_EMAIL : undefined;
|
||||
const devAdminPassword = isDev ? process.env.DEV_LOGIN_ADMIN_PASSWORD : undefined;
|
||||
const devAdminPassword = isDev
|
||||
? process.env.DEV_LOGIN_ADMIN_PASSWORD
|
||||
: undefined;
|
||||
const devParentEmail = isDev ? process.env.DEV_LOGIN_PARENT_EMAIL : undefined;
|
||||
const devParentPassword = isDev ? process.env.DEV_LOGIN_PARENT_PASSWORD : undefined;
|
||||
const devParentPassword = isDev
|
||||
? process.env.DEV_LOGIN_PARENT_PASSWORD
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
|
||||
@@ -43,6 +51,15 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
<CardDescription>Willkommen zurück.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{resolvedSearchParams?.passwordChanged === "1" ? (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded-lg bg-[var(--good-soft)] p-3 text-sm text-[var(--ink)]"
|
||||
>
|
||||
Dein Passwort wurde geändert. Melde dich mit dem neuen Passwort
|
||||
an.
|
||||
</p>
|
||||
) : null}
|
||||
<LoginForm
|
||||
callbackUrl={callbackUrl}
|
||||
devAdminEmail={devAdminEmail}
|
||||
@@ -52,7 +69,10 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
/>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Noch keinen Account?{" "}
|
||||
<Link href="/register" className="font-medium text-foreground underline">
|
||||
<Link
|
||||
href="/register"
|
||||
className="font-medium text-foreground underline"
|
||||
>
|
||||
Kita registrieren
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { isPasswordResetValid } from "@/lib/password-management";
|
||||
import { PasswordForm } from "../../passwort-vergessen/password-form";
|
||||
export const metadata = {
|
||||
title: "Neues Passwort · Kita-Planer",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
export default async function ResetPasswordPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ token: string }>;
|
||||
}) {
|
||||
const { token } = await params;
|
||||
const valid = await isPasswordResetValid(token);
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[var(--bg)] px-5 py-12">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{valid
|
||||
? "Neues Passwort festlegen"
|
||||
: "Dieser Link ist nicht mehr gültig"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{valid
|
||||
? "Wähle ein neues Passwort für dein Kita-Planer-Konto."
|
||||
: "Der Link ist abgelaufen, wurde bereits verwendet oder ist ungültig. Du kannst einen neuen Link anfordern."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{valid ? (
|
||||
<PasswordForm mode="reset" token={token} />
|
||||
) : (
|
||||
<Link
|
||||
href="/passwort-vergessen"
|
||||
className="text-sm font-semibold text-[var(--accent-deep)] underline underline-offset-4"
|
||||
>
|
||||
Neuen Link anfordern
|
||||
</Link>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use server";
|
||||
import { z } from "zod";
|
||||
import { after } from "next/server";
|
||||
import { limitPublicAction } from "@/lib/rate-limit";
|
||||
import {
|
||||
requestPasswordReset,
|
||||
passwordResetMessage,
|
||||
resetPassword,
|
||||
changeOwnPassword,
|
||||
} from "@/lib/password-management";
|
||||
import { hashToken } from "@/lib/tokens";
|
||||
import { requireSession } from "@/lib/auth-utils";
|
||||
import { publicActionError } from "@/lib/action-error";
|
||||
|
||||
export type PasswordActionState = {
|
||||
error?: string;
|
||||
message?: string;
|
||||
success?: boolean;
|
||||
};
|
||||
export async function requestResetAction(
|
||||
_previous: PasswordActionState,
|
||||
form: FormData,
|
||||
): Promise<PasswordActionState> {
|
||||
const email = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(254)
|
||||
.email()
|
||||
.toLowerCase()
|
||||
.safeParse(form.get("email"));
|
||||
if (!email.success)
|
||||
return { error: "Bitte gib eine gültige E-Mail-Adresse ein." };
|
||||
try {
|
||||
if (await limitPublicAction("password-reset-request", email.data, 3, 3600))
|
||||
after(async () => {
|
||||
try {
|
||||
await requestPasswordReset(email.data);
|
||||
} catch {
|
||||
console.error(
|
||||
"Passwort-Wiederherstellung konnte nicht vorbereitet werden.",
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
console.error(
|
||||
"Passwort-Wiederherstellung konnte nicht vorbereitet werden.",
|
||||
);
|
||||
}
|
||||
return { success: true, message: passwordResetMessage };
|
||||
}
|
||||
export async function finishResetAction(
|
||||
_previous: PasswordActionState,
|
||||
form: FormData,
|
||||
): Promise<PasswordActionState> {
|
||||
const token = form.get("token");
|
||||
if (typeof token !== "string" || !/^[a-f0-9]{64}$/i.test(token))
|
||||
return {
|
||||
error: "Dieser Link ist ungültig. Bitte fordere einen neuen Link an.",
|
||||
};
|
||||
try {
|
||||
if (
|
||||
!(await limitPublicAction(
|
||||
"password-reset-complete",
|
||||
hashToken(token),
|
||||
5,
|
||||
900,
|
||||
))
|
||||
)
|
||||
return { error: "Zu viele Versuche. Bitte versuche es später erneut." };
|
||||
await resetPassword({
|
||||
token,
|
||||
password: form.get("password"),
|
||||
confirmation: form.get("confirmation"),
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message:
|
||||
"Dein Passwort wurde geändert. Melde dich mit deinem neuen Passwort an.",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: publicActionError(
|
||||
error,
|
||||
"Das Passwort konnte nicht geändert werden. Bitte versuche es erneut.",
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
export async function changePasswordAction(
|
||||
_previous: PasswordActionState,
|
||||
form: FormData,
|
||||
): Promise<PasswordActionState> {
|
||||
const { user } = await requireSession();
|
||||
try {
|
||||
if (!(await limitPublicAction("password-change", user.id, 5, 900)))
|
||||
return { error: "Zu viele Versuche. Bitte versuche es später erneut." };
|
||||
await changeOwnPassword(
|
||||
{ id: user.id, sessionVersion: user.sessionVersion },
|
||||
{
|
||||
currentPassword: form.get("currentPassword"),
|
||||
password: form.get("password"),
|
||||
confirmation: form.get("confirmation"),
|
||||
},
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
message: "Dein Passwort wurde geändert. Bitte melde dich erneut an.",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: publicActionError(
|
||||
error,
|
||||
"Das Passwort konnte nicht geändert werden. Bitte versuche es erneut.",
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { PasswordForm } from "./password-form";
|
||||
export const metadata = {
|
||||
title: "Passwort vergessen · Kita-Planer",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[var(--bg)] px-5 py-12">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Passwort vergessen?</CardTitle>
|
||||
<CardDescription>
|
||||
Fordere einen persönlichen Link an, um ein neues Passwort
|
||||
festzulegen. Der Link ist 30 Minuten gültig.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<PasswordForm mode="request" />
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-block text-sm text-[var(--accent-deep)] underline underline-offset-4"
|
||||
>
|
||||
Zurück zur Anmeldung
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useActionState, useEffect, useId } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
requestResetAction,
|
||||
finishResetAction,
|
||||
changePasswordAction,
|
||||
type PasswordActionState,
|
||||
} from "./actions";
|
||||
|
||||
export function PasswordForm({
|
||||
mode,
|
||||
token,
|
||||
}: {
|
||||
mode: "request" | "reset" | "change";
|
||||
token?: string;
|
||||
}) {
|
||||
const action =
|
||||
mode === "request"
|
||||
? requestResetAction
|
||||
: mode === "reset"
|
||||
? finishResetAction
|
||||
: changePasswordAction;
|
||||
const [state, formAction, pending] = useActionState(
|
||||
action,
|
||||
{} as PasswordActionState,
|
||||
);
|
||||
const id = useId();
|
||||
useEffect(() => {
|
||||
if (mode === "change" && state.success)
|
||||
window.location.replace(
|
||||
new URL("/login?passwordChanged=1", window.location.origin).href,
|
||||
);
|
||||
}, [mode, state.success]);
|
||||
if (state.success)
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p
|
||||
role="status"
|
||||
className="rounded-xl border border-[var(--hairline)] bg-[var(--good-soft)] p-4 text-sm leading-6 text-[var(--ink)]"
|
||||
>
|
||||
{state.message}
|
||||
</p>
|
||||
{mode !== "request" ? (
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm font-semibold text-[var(--accent-deep)] underline underline-offset-4"
|
||||
>
|
||||
Zur Anmeldung
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<form action={formAction} className="space-y-4">
|
||||
{token ? <input type="hidden" name="token" value={token} /> : null}
|
||||
{mode === "request" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-email`}>E-Mail-Adresse</Label>
|
||||
<Input
|
||||
id={`${id}-email`}
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
maxLength={254}
|
||||
required
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{mode === "change" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-current`}>Aktuelles Passwort</Label>
|
||||
<Input
|
||||
id={`${id}-current`}
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-password`}>Neues Passwort</Label>
|
||||
<Input
|
||||
id={`${id}-password`}
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
disabled={pending}
|
||||
/>
|
||||
<p className="text-xs leading-5 text-[var(--ink-soft)]">
|
||||
Mindestens 8 Zeichen. Verwende ein eigenes Passwort für den
|
||||
Kita-Planer.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-confirmation`}>
|
||||
Neues Passwort wiederholen
|
||||
</Label>
|
||||
<Input
|
||||
id={`${id}-confirmation`}
|
||||
name="confirmation"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-[var(--ink-soft)]">
|
||||
Nach dem Speichern werden alle bisherigen Anmeldungen beendet.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{state.error ? (
|
||||
<p role="alert" className="text-sm leading-6 text-[var(--danger)]">
|
||||
{state.error}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" className="w-full sm:w-auto" disabled={pending}>
|
||||
{pending
|
||||
? "Wird verarbeitet …"
|
||||
: mode === "request"
|
||||
? "Link anfordern"
|
||||
: "Passwort speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ type UserRoleDto =
|
||||
|
||||
type OnboardingDialogProps = {
|
||||
user: {
|
||||
id: string;
|
||||
phone: string | null;
|
||||
street: string | null;
|
||||
postalCode: string | null;
|
||||
@@ -43,7 +44,7 @@ type OnboardingDialogProps = {
|
||||
} | null;
|
||||
};
|
||||
|
||||
const HIDE_ONBOARDING_DIALOG_KEY = "hideOnboardingDialog";
|
||||
const onboardingKey = (userId: string) => `hideOnboardingDialog:${userId}`;
|
||||
|
||||
export function OnboardingDialog({ user, family }: OnboardingDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -79,14 +80,18 @@ export function OnboardingDialog({ user, family }: OnboardingDialogProps) {
|
||||
}, [family?.childrenCount, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.isComplete) return;
|
||||
|
||||
const hidden = localStorage.getItem(HIDE_ONBOARDING_DIALOG_KEY);
|
||||
if (hidden !== "true") {
|
||||
const timer = window.setTimeout(() => setOpen(true), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
let hidden: string | null = null;
|
||||
try {
|
||||
hidden = localStorage.getItem(onboardingKey(user.id));
|
||||
} catch {
|
||||
/* Unavailable browser storage does not prevent using the app. */
|
||||
}
|
||||
}, [status.isComplete]);
|
||||
const timer = window.setTimeout(
|
||||
() => setOpen(!status.isComplete && hidden !== "true"),
|
||||
0,
|
||||
);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [status.isComplete, user.id]);
|
||||
|
||||
if (status.isComplete) {
|
||||
return null;
|
||||
@@ -97,7 +102,11 @@ export function OnboardingDialog({ user, family }: OnboardingDialogProps) {
|
||||
: "Haushalt & Kinder einrichten";
|
||||
|
||||
function hidePermanently() {
|
||||
localStorage.setItem(HIDE_ONBOARDING_DIALOG_KEY, "true");
|
||||
try {
|
||||
localStorage.setItem(onboardingKey(user.id), "true");
|
||||
} catch {
|
||||
/* Close for this visit if storage is unavailable. */
|
||||
}
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
@@ -113,8 +122,8 @@ export function OnboardingDialog({ user, family }: OnboardingDialogProps) {
|
||||
Dein Kita-Profil ist fast bereit
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-emerald-50/80">
|
||||
Vervollständige die wichtigsten Daten, damit Notdienst,
|
||||
Adressbuch und Familienverwaltung reibungslos funktionieren.
|
||||
Vervollständige die wichtigsten Daten, damit Notdienst, Adressbuch
|
||||
und Familienverwaltung reibungslos funktionieren.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Megaphone,
|
||||
Clock3,
|
||||
BellRing,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
|
||||
import { NavItem } from "@/components/ui/nav-item";
|
||||
@@ -21,6 +22,14 @@ import { NavItem } from "@/components/ui/nav-item";
|
||||
import { UserRole } from "@prisma/client";
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
section: "Verwaltung",
|
||||
href: "/dashboard/zugriffsrechte",
|
||||
label: "Zugriffsrechte",
|
||||
icon: ShieldCheck,
|
||||
exact: true,
|
||||
allowedRoles: [UserRole.ADMIN],
|
||||
},
|
||||
{
|
||||
section: "Heute",
|
||||
href: "/dashboard/notdienst/alarm",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { UserRole } from "@prisma/client";
|
||||
|
||||
import { kitaRoleLabels } from "@/lib/kita-roles";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type RoleBadgeProps = {
|
||||
@@ -7,14 +9,6 @@ type RoleBadgeProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
SUPERADMIN: "Admin",
|
||||
ADMIN: "Vorstand",
|
||||
KOORDINATOR: "Koordinator",
|
||||
ELTERN: "Eltern",
|
||||
ERZIEHER: "Erzieher",
|
||||
};
|
||||
|
||||
export function RoleBadge({ role, className }: RoleBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
@@ -23,7 +17,7 @@ export function RoleBadge({ role, className }: RoleBadgeProps) {
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{roleLabels[role] ?? role}
|
||||
{kitaRoleLabels[role]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { baseBodyStyle, colors } from "./_styles";
|
||||
|
||||
export function PasswordResetEmail({
|
||||
name,
|
||||
resetLink,
|
||||
}: {
|
||||
name: string;
|
||||
resetLink: string;
|
||||
}) {
|
||||
return (
|
||||
<Html lang="de">
|
||||
<Head />
|
||||
<Preview>Dein Link für ein neues Kita-Planer-Passwort</Preview>
|
||||
<Body style={baseBodyStyle}>
|
||||
<Container
|
||||
style={{
|
||||
maxWidth: "520px",
|
||||
padding: "32px",
|
||||
backgroundColor: colors.cardBg,
|
||||
}}
|
||||
>
|
||||
<Heading>Neues Passwort festlegen</Heading>
|
||||
<Text>
|
||||
Hallo {name}, du hast einen Link angefordert, um dein
|
||||
Kita-Planer-Passwort zurückzusetzen.
|
||||
</Text>
|
||||
<Text>
|
||||
Der Link ist 30 Minuten gültig und kann einmal verwendet werden.
|
||||
Wenn du diese Anfrage nicht gestellt hast, kannst du die Nachricht
|
||||
ignorieren. Dein Passwort bleibt dann unverändert.
|
||||
</Text>
|
||||
<Button
|
||||
href={resetLink}
|
||||
style={{
|
||||
backgroundColor: colors.brandGreenAccent,
|
||||
color: "#ffffff",
|
||||
padding: "12px 18px",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
>
|
||||
Neues Passwort festlegen
|
||||
</Button>
|
||||
<Text>Falls der Button nicht funktioniert: {resetLink}</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
+18
-3
@@ -39,14 +39,29 @@ export async function deliverAlarmEmails(
|
||||
include: { user: true, alarm: { include: { kita: true } } },
|
||||
});
|
||||
const { user, alarm } = job;
|
||||
let eligible = user.kitaId === alarm.kitaId && !user.disabledAt && !!user.emailVerifiedAt && !!user.privacyPolicyAcceptedAt && (!user.lockedUntil || user.lockedUntil <= now);
|
||||
let eligible =
|
||||
user.kitaId === alarm.kitaId &&
|
||||
!user.disabledAt &&
|
||||
!!user.emailVerifiedAt &&
|
||||
!!user.privacyPolicyAcceptedAt &&
|
||||
(!user.lockedUntil || user.lockedUntil <= now);
|
||||
if (job.kind === "MANAGER") eligible &&= managerRoles.includes(user.role);
|
||||
else
|
||||
eligible &&= user.role === "ELTERN" && user.familyId === alarm.familyId;
|
||||
eligible &&=
|
||||
["ELTERN", "ADMIN", "KOORDINATOR"].includes(user.role) &&
|
||||
user.familyId === alarm.familyId;
|
||||
if (job.kind === "REQUEST") {
|
||||
const token = job.path.split("/").pop();
|
||||
const recipient = await prisma.notdienstAlarmRecipient.findFirst({
|
||||
where: { alarmId: alarm.id, userId: user.id, sessionVersion: user.sessionVersion, OR: [{ token: hashToken(token ?? "") }, ...(token?.length === 36 ? [{ token }] : [])] },
|
||||
where: {
|
||||
alarmId: alarm.id,
|
||||
userId: user.id,
|
||||
sessionVersion: user.sessionVersion,
|
||||
OR: [
|
||||
{ token: hashToken(token ?? "") },
|
||||
...(token?.length === 36 ? [{ token }] : []),
|
||||
],
|
||||
},
|
||||
});
|
||||
eligible &&=
|
||||
!!recipient && alarm.status === "PENDING" && alarm.expiresAt > now;
|
||||
|
||||
+52
-16
@@ -114,22 +114,30 @@ async function queue(
|
||||
const users =
|
||||
audience === "managers"
|
||||
? await tx.user.findMany({
|
||||
where: { kitaId: alarm.kitaId, role: { in: managerRoles }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
where: {
|
||||
kitaId: alarm.kitaId,
|
||||
role: { in: managerRoles },
|
||||
emailVerifiedAt: { not: null },
|
||||
disabledAt: null,
|
||||
privacyPolicyAcceptedAt: { not: null },
|
||||
},
|
||||
})
|
||||
: await tx.user.findMany({
|
||||
where: {
|
||||
kitaId: alarm.kitaId,
|
||||
familyId: alarm.familyId,
|
||||
role: "ELTERN",
|
||||
emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null },
|
||||
role: { in: ["ELTERN", "ADMIN", "KOORDINATOR"] },
|
||||
emailVerifiedAt: { not: null },
|
||||
disabledAt: null,
|
||||
privacyPolicyAcceptedAt: { not: null },
|
||||
alarmRecipients: { some: { alarmId: alarm.id } },
|
||||
},
|
||||
});
|
||||
const jobs: string[] = [];
|
||||
for (const user of users) {
|
||||
const recipient =
|
||||
kind === "REQUEST" ? requestTokens?.get(user.id) : null;
|
||||
if (kind === "REQUEST" && !recipient) throw new Error("Request token missing");
|
||||
const recipient = kind === "REQUEST" ? requestTokens?.get(user.id) : null;
|
||||
if (kind === "REQUEST" && !recipient)
|
||||
throw new Error("Request token missing");
|
||||
await tx.notdienstAlarmNotification.create({
|
||||
data: { alarmId: alarm.id, userId: user.id, eventKey, message },
|
||||
});
|
||||
@@ -204,7 +212,15 @@ export async function executeAlarmCommand(
|
||||
include: {
|
||||
family: {
|
||||
include: {
|
||||
users: { where: { role: "ELTERN", kitaId: actor.kitaId, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } } },
|
||||
users: {
|
||||
where: {
|
||||
role: { in: ["ELTERN", "ADMIN", "KOORDINATOR"] },
|
||||
kitaId: actor.kitaId,
|
||||
emailVerifiedAt: { not: null },
|
||||
disabledAt: null,
|
||||
privacyPolicyAcceptedAt: { not: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -221,10 +237,14 @@ export async function executeAlarmCommand(
|
||||
if (!assignment.kita.notdienstModuleEnabled)
|
||||
throw new UserFacingError("Notdienst ist für diese Kita deaktiviert.");
|
||||
if (assignment.plan.status !== "PUBLISHED")
|
||||
throw new UserFacingError("Bitte den Notdienstplan zuerst veröffentlichen.");
|
||||
throw new UserFacingError(
|
||||
"Bitte den Notdienstplan zuerst veröffentlichen.",
|
||||
);
|
||||
const date = dateKey(assignment.date);
|
||||
if (date < todayKey(now))
|
||||
throw new UserFacingError("Vergangene Notdienste können nicht alarmiert werden.");
|
||||
throw new UserFacingError(
|
||||
"Vergangene Notdienste können nicht alarmiert werden.",
|
||||
);
|
||||
const expiresAt = endOfAlarm(date, input.wholeDay, input.endMinute);
|
||||
if (expiresAt <= now)
|
||||
throw new UserFacingError("Dieser Einsatz ist bereits beendet.");
|
||||
@@ -245,7 +265,9 @@ export async function executeAlarmCommand(
|
||||
"Für diese Einteilung besteht bereits ein aktiver Alarm.",
|
||||
);
|
||||
const parents = assignment.child.family.users;
|
||||
const requestTokens = new Map(parents.map(parent => [parent.id, newToken()]));
|
||||
const requestTokens = new Map(
|
||||
parents.map((parent) => [parent.id, newToken()]),
|
||||
);
|
||||
if (!parents.length)
|
||||
throw new UserFacingError(
|
||||
"Für diesen Haushalt sind keine Elternkonten hinterlegt.",
|
||||
@@ -353,7 +375,9 @@ export async function executeAlarmCommand(
|
||||
);
|
||||
const reason = note.parse(data.reason);
|
||||
if (command === "approveLate" && alarm.status !== "LATE_CONFIRMATION")
|
||||
throw new UserFacingError("Es liegt keine verspätete Zusage zur Freigabe vor.");
|
||||
throw new UserFacingError(
|
||||
"Es liegt keine verspätete Zusage zur Freigabe vor.",
|
||||
);
|
||||
const approve =
|
||||
command === "approveLate" || z.boolean().parse(data.approve);
|
||||
const updated = await tx.notdienstAlarm.update({
|
||||
@@ -401,7 +425,12 @@ export async function executeAlarmCommand(
|
||||
|
||||
async function recipientForToken(tx: Tx, token: string) {
|
||||
const recipient = await tx.notdienstAlarmRecipient.findFirst({
|
||||
where: { OR: [{ token: hashToken(token) }, ...(token.length === 36 ? [{ token }] : [])] },
|
||||
where: {
|
||||
OR: [
|
||||
{ token: hashToken(token) },
|
||||
...(token.length === 36 ? [{ token }] : []),
|
||||
],
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
alarm: { include: { kita: { select: { name: true } } } },
|
||||
@@ -409,9 +438,11 @@ async function recipientForToken(tx: Tx, token: string) {
|
||||
});
|
||||
if (
|
||||
!recipient ||
|
||||
recipient.user.role !== "ELTERN" ||
|
||||
!["ELTERN", "ADMIN", "KOORDINATOR"].includes(recipient.user.role) ||
|
||||
recipient.sessionVersion !== recipient.user.sessionVersion ||
|
||||
!!recipient.user.disabledAt || !recipient.user.emailVerifiedAt || !recipient.user.privacyPolicyAcceptedAt ||
|
||||
!!recipient.user.disabledAt ||
|
||||
!recipient.user.emailVerifiedAt ||
|
||||
!recipient.user.privacyPolicyAcceptedAt ||
|
||||
recipient.alarm.expiresAt <= new Date() ||
|
||||
(recipient.user.lockedUntil && recipient.user.lockedUntil > new Date()) ||
|
||||
recipient.user.kitaId !== recipient.alarm.kitaId ||
|
||||
@@ -423,7 +454,12 @@ async function recipientForToken(tx: Tx, token: string) {
|
||||
export async function getPublicAlarm(token: string) {
|
||||
if (!tokenSchema.safeParse(token).success) return null;
|
||||
const legacy = await prisma.notdienstAlert.findFirst({
|
||||
where: { OR: [{ confirmationToken: hashToken(token) }, ...(token.length === 36 ? [{ confirmationToken: token }] : [])] },
|
||||
where: {
|
||||
OR: [
|
||||
{ confirmationToken: hashToken(token) },
|
||||
...(token.length === 36 ? [{ confirmationToken: token }] : []),
|
||||
],
|
||||
},
|
||||
select: { alarmId: true, assignmentId: true },
|
||||
});
|
||||
if (legacy && !legacy.alarmId)
|
||||
@@ -591,7 +627,7 @@ export async function importLegacyAlarms(
|
||||
const old = assignment.alerts;
|
||||
const valid = old.filter(
|
||||
(a) =>
|
||||
a.parentUser.role === "ELTERN" &&
|
||||
["ELTERN", "ADMIN", "KOORDINATOR"].includes(a.parentUser.role) &&
|
||||
a.parentUser.familyId === assignment.child.familyId &&
|
||||
a.parentUser.kitaId === assignment.kitaId,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { UserFacingError } from "@/lib/action-error";
|
||||
|
||||
// Serialize role changes, account deletion and support locks for the same Kita.
|
||||
export async function lockKitaAccounts(
|
||||
tx: Prisma.TransactionClient,
|
||||
kitaId: string,
|
||||
) {
|
||||
const rows = await tx.$queryRaw<
|
||||
{ id: string }[]
|
||||
>`SELECT id FROM kitas WHERE id = ${kitaId} FOR UPDATE`;
|
||||
if (!rows.length) throw new UserFacingError("Kita wurde nicht gefunden.");
|
||||
}
|
||||
|
||||
export async function assertKitaAdminRemains(
|
||||
tx: Prisma.TransactionClient,
|
||||
kitaId: string,
|
||||
excludedUserIds: string[],
|
||||
) {
|
||||
const removesAdmin = await tx.user.count({
|
||||
where: { kitaId, id: { in: excludedUserIds }, role: "ADMIN" },
|
||||
});
|
||||
if (!removesAdmin) return;
|
||||
const now = new Date();
|
||||
const remaining = await tx.user.count({
|
||||
where: {
|
||||
kitaId,
|
||||
id: { notIn: excludedUserIds },
|
||||
role: "ADMIN",
|
||||
disabledAt: null,
|
||||
emailVerifiedAt: { not: null },
|
||||
privacyPolicyAcceptedAt: { not: null },
|
||||
passwordHash: { not: "" },
|
||||
OR: [{ lockedUntil: null }, { lockedUntil: { lte: now } }],
|
||||
},
|
||||
});
|
||||
if (!remaining)
|
||||
throw new UserFacingError(
|
||||
"Mindestens ein aktiver Kita-Admin muss erhalten bleiben. Ernenne zuerst eine weitere Person mit aktiviertem Zugang und Datenschutzzustimmung.",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import "server-only";
|
||||
import { AuditTargetType } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { serializableTransaction } from "@/lib/transaction";
|
||||
import { UserFacingError } from "@/lib/action-error";
|
||||
import {
|
||||
assertKitaAdminRemains,
|
||||
lockKitaAccounts,
|
||||
} from "@/lib/kita-admin-guard";
|
||||
import { availableKitaRoles } from "@/lib/kita-roles";
|
||||
|
||||
const inputSchema = z.object({
|
||||
userId: z.string().min(1).max(64),
|
||||
role: z.enum(["ADMIN", "KOORDINATOR", "ELTERN", "ERZIEHER"]),
|
||||
expectedRole: z.enum(["ADMIN", "KOORDINATOR", "ELTERN", "ERZIEHER"]),
|
||||
});
|
||||
|
||||
type Actor = { id: string; kitaId: string; sessionVersion: number };
|
||||
export async function changeKitaRole(actor: Actor, raw: unknown) {
|
||||
const input = inputSchema.safeParse(raw);
|
||||
if (!input.success) throw new UserFacingError("Ungültige Rollenänderung.");
|
||||
return serializableTransaction(async (tx) => {
|
||||
await lockKitaAccounts(tx, actor.kitaId);
|
||||
const currentActor = await tx.user.findFirst({
|
||||
where: {
|
||||
id: actor.id,
|
||||
kitaId: actor.kitaId,
|
||||
role: "ADMIN",
|
||||
sessionVersion: actor.sessionVersion,
|
||||
disabledAt: null,
|
||||
emailVerifiedAt: { not: null },
|
||||
privacyPolicyAcceptedAt: { not: null },
|
||||
OR: [{ lockedUntil: null }, { lockedUntil: { lte: new Date() } }],
|
||||
},
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
if (!currentActor)
|
||||
throw new UserFacingError(
|
||||
"Du darfst die Zugriffsrechte nicht mehr ändern. Bitte lade die Seite neu.",
|
||||
);
|
||||
const person = await tx.user.findFirst({
|
||||
where: { id: input.data.userId, kitaId: actor.kitaId },
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
role: true,
|
||||
familyId: true,
|
||||
staffProfile: { select: { id: true, kitaId: true } },
|
||||
},
|
||||
});
|
||||
if (!person || person.role === "SUPERADMIN")
|
||||
throw new UserFacingError(
|
||||
"Dieses Kita-Konto kann hier nicht geändert werden.",
|
||||
);
|
||||
if (person.role !== input.data.expectedRole)
|
||||
throw new UserFacingError(
|
||||
"Die Rolle wurde inzwischen geändert. Bitte lade die Seite neu.",
|
||||
);
|
||||
if (person.staffProfile && person.staffProfile.kitaId !== actor.kitaId)
|
||||
throw new UserFacingError(
|
||||
"Das Personalprofil gehört nicht zu dieser Kita.",
|
||||
);
|
||||
if (
|
||||
!availableKitaRoles({
|
||||
familyId: person.familyId,
|
||||
hasStaffProfile: !!person.staffProfile,
|
||||
}).includes(input.data.role)
|
||||
)
|
||||
throw new UserFacingError(
|
||||
person.staffProfile
|
||||
? "Ein Personalprofil benötigt die Rolle Fachkraft oder Kita-Admin."
|
||||
: "Eltern benötigen einen Haushalt; die Fachkraftrolle benötigt ein Personalprofil.",
|
||||
);
|
||||
if (person.role === input.data.role)
|
||||
return { changed: false, ownRoleChanged: false };
|
||||
if (person.role === "ADMIN")
|
||||
await assertKitaAdminRemains(tx, actor.kitaId, [person.id]);
|
||||
await tx.user.update({
|
||||
where: { id: person.id, kitaId: actor.kitaId },
|
||||
data: { role: input.data.role },
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
actorUserId: currentActor.id,
|
||||
actorEmail: currentActor.email,
|
||||
action: "USER_ROLE_CHANGE",
|
||||
targetType: AuditTargetType.USER,
|
||||
targetId: person.id,
|
||||
targetLabel: `${person.firstName} ${person.lastName}`,
|
||||
targetKitaId: actor.kitaId,
|
||||
metadata: { previousRole: person.role, newRole: input.data.role },
|
||||
},
|
||||
});
|
||||
// Auth reloads the role from the database on every request. Keeping sessionVersion
|
||||
// preserves already-issued household alarm links and existing login sessions.
|
||||
return { changed: true, ownRoleChanged: person.id === actor.id };
|
||||
});
|
||||
}
|
||||
|
||||
export async function getKitaRoleHistory(kitaId: string) {
|
||||
return prisma.auditLog.findMany({
|
||||
where: { targetKitaId: kitaId, action: "USER_ROLE_CHANGE" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 15,
|
||||
select: {
|
||||
id: true,
|
||||
targetLabel: true,
|
||||
actorEmail: true,
|
||||
metadata: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { UserRole } from "@prisma/client";
|
||||
|
||||
export const kitaRoleLabels: Record<UserRole, string> = {
|
||||
ADMIN: "Kita-Admin",
|
||||
KOORDINATOR: "Notdienst-Koordination",
|
||||
ELTERN: "Eltern",
|
||||
ERZIEHER: "Fachkraft",
|
||||
SUPERADMIN: "Systemadmin",
|
||||
};
|
||||
|
||||
export function availableKitaRoles(person: {
|
||||
familyId: string | null;
|
||||
hasStaffProfile: boolean;
|
||||
}): UserRole[] {
|
||||
if (person.hasStaffProfile) return ["ERZIEHER", "ADMIN"];
|
||||
return person.familyId
|
||||
? ["ELTERN", "KOORDINATOR", "ADMIN"]
|
||||
: ["KOORDINATOR", "ADMIN"];
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { createElement } from "react";
|
||||
import { compare, hash } from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { serializableTransaction } from "@/lib/transaction";
|
||||
import { hashToken, newToken } from "@/lib/tokens";
|
||||
import { passwordSchema } from "@/lib/password";
|
||||
import { sendAppEmail } from "@/lib/mail";
|
||||
import { PasswordResetEmail } from "@/emails/PasswordResetEmail";
|
||||
import { UserFacingError } from "@/lib/action-error";
|
||||
|
||||
const RESET_PREFIX = "password-reset:";
|
||||
const resetScope = (userId: string) =>
|
||||
`${RESET_PREFIX}${Buffer.from(userId).toString("base64url")}:`;
|
||||
function resetIdentity(identifier: string) {
|
||||
const match = /^password-reset:([a-zA-Z0-9_-]+):(\d+)$/.exec(identifier);
|
||||
if (!match) return null;
|
||||
const sessionVersion = Number(match[2]);
|
||||
if (!Number.isSafeInteger(sessionVersion)) return null;
|
||||
return {
|
||||
id: Buffer.from(match[1], "base64url").toString("utf8"),
|
||||
sessionVersion,
|
||||
};
|
||||
}
|
||||
const tokenSchema = z.string().regex(/^[a-f0-9]{64}$/i);
|
||||
export const passwordResetMessage =
|
||||
"Wenn für diese E-Mail-Adresse ein aktiviertes Konto besteht, erhältst du einen Link für ein neues Passwort. Prüfe auch deinen Spamordner.";
|
||||
const newPasswordSchema = z
|
||||
.object({ password: passwordSchema, confirmation: z.string() })
|
||||
.refine((input) => input.password === input.confirmation, {
|
||||
message: "Die neuen Passwörter stimmen nicht überein.",
|
||||
path: ["confirmation"],
|
||||
});
|
||||
const resetSchema = newPasswordSchema.and(z.object({ token: tokenSchema }));
|
||||
const invalidLink = () =>
|
||||
new UserFacingError(
|
||||
"Dieser Link ist ungültig, abgelaufen oder wurde bereits verwendet. Bitte fordere einen neuen Link an.",
|
||||
);
|
||||
|
||||
function siteOrigin() {
|
||||
const base =
|
||||
process.env.NEXT_PUBLIC_SITE_URL ||
|
||||
process.env.AUTH_URL ||
|
||||
process.env.NEXTAUTH_URL;
|
||||
if (!base && process.env.NODE_ENV !== "development")
|
||||
throw new Error("App-Adresse fehlt.");
|
||||
const url = new URL(base || "http://localhost:3000");
|
||||
if (
|
||||
!["http:", "https:"].includes(url.protocol) ||
|
||||
url.username ||
|
||||
url.password
|
||||
)
|
||||
throw new Error("Ungültige App-Adresse.");
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
export async function requestPasswordReset(email: string, send = sendAppEmail) {
|
||||
const parsed = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(254)
|
||||
.email()
|
||||
.toLowerCase()
|
||||
.safeParse(email);
|
||||
if (!parsed.success) return;
|
||||
const resetToken = newToken();
|
||||
const tokenHash = hashToken(resetToken);
|
||||
const job = await serializableTransaction(async (tx) => {
|
||||
const user = await tx.user.findUnique({ where: { email: parsed.data } });
|
||||
if (!user || user.disabledAt || !user.emailVerifiedAt || !user.passwordHash)
|
||||
return null;
|
||||
await tx.$queryRaw`SELECT id FROM users WHERE id = ${user.id} FOR UPDATE`;
|
||||
const fresh = await tx.user.findUniqueOrThrow({ where: { id: user.id } });
|
||||
if (
|
||||
fresh.email !== parsed.data ||
|
||||
fresh.disabledAt ||
|
||||
!fresh.emailVerifiedAt ||
|
||||
!fresh.passwordHash
|
||||
)
|
||||
return null;
|
||||
const identifier = resetScope(user.id) + fresh.sessionVersion;
|
||||
await tx.verificationToken.deleteMany({
|
||||
where: { identifier: { startsWith: resetScope(user.id) } },
|
||||
});
|
||||
await tx.verificationToken.create({
|
||||
data: {
|
||||
identifier,
|
||||
token: tokenHash,
|
||||
expires: new Date(Date.now() + 30 * 60_000),
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: user.id,
|
||||
email: fresh.email,
|
||||
name: fresh.firstName,
|
||||
identifier,
|
||||
};
|
||||
});
|
||||
if (!job) return;
|
||||
try {
|
||||
const result = await send({
|
||||
to: job.email,
|
||||
subject: "Kita-Planer: Neues Passwort festlegen",
|
||||
react: createElement(PasswordResetEmail, {
|
||||
name: job.name,
|
||||
resetLink: `${siteOrigin()}/neues-passwort/${resetToken}`,
|
||||
}),
|
||||
idempotencyKey: `password-reset/${tokenHash}`,
|
||||
});
|
||||
if (result.success) return;
|
||||
} catch {
|
||||
/* Return the same public response, including transport failure. */
|
||||
}
|
||||
await prisma.verificationToken.deleteMany({
|
||||
where: { identifier: job.identifier, token: tokenHash },
|
||||
});
|
||||
}
|
||||
|
||||
export async function isPasswordResetValid(token: string) {
|
||||
if (!tokenSchema.safeParse(token).success) return false;
|
||||
const entry = await prisma.verificationToken.findUnique({
|
||||
where: { token: hashToken(token) },
|
||||
});
|
||||
if (!entry || !resetIdentity(entry.identifier) || entry.expires <= new Date())
|
||||
return false;
|
||||
return !!(await prisma.user.findFirst({
|
||||
where: {
|
||||
...resetIdentity(entry.identifier)!,
|
||||
disabledAt: null,
|
||||
emailVerifiedAt: { not: null },
|
||||
passwordHash: { not: "" },
|
||||
},
|
||||
select: { id: true },
|
||||
}));
|
||||
}
|
||||
|
||||
export async function resetPassword(raw: unknown) {
|
||||
const input = resetSchema.safeParse(raw);
|
||||
if (!input.success)
|
||||
throw new UserFacingError(
|
||||
input.error.issues[0]?.message ?? "Bitte die Eingaben prüfen.",
|
||||
);
|
||||
if (!(await isPasswordResetValid(input.data.token))) throw invalidLink();
|
||||
const passwordHash = await hash(input.data.password, 12);
|
||||
await serializableTransaction(async (tx) => {
|
||||
const tokenHash = hashToken(input.data.token);
|
||||
const entry = await tx.verificationToken.findUnique({
|
||||
where: { token: tokenHash },
|
||||
});
|
||||
if (
|
||||
!entry ||
|
||||
!resetIdentity(entry.identifier) ||
|
||||
entry.expires <= new Date()
|
||||
)
|
||||
throw invalidLink();
|
||||
const identity = resetIdentity(entry.identifier)!;
|
||||
const userId = identity.id;
|
||||
await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId} FOR UPDATE`;
|
||||
const user = await tx.user.findFirst({
|
||||
where: {
|
||||
...identity,
|
||||
disabledAt: null,
|
||||
emailVerifiedAt: { not: null },
|
||||
passwordHash: { not: "" },
|
||||
},
|
||||
});
|
||||
if (!user) throw invalidLink();
|
||||
const consumed = await tx.verificationToken.deleteMany({
|
||||
where: {
|
||||
identifier: entry.identifier,
|
||||
token: tokenHash,
|
||||
expires: { gt: new Date() },
|
||||
},
|
||||
});
|
||||
if (consumed.count !== 1) throw invalidLink();
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
passwordHash,
|
||||
sessionVersion: { increment: 1 },
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
},
|
||||
});
|
||||
await tx.verificationToken.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ identifier: userId },
|
||||
{ identifier: { startsWith: resetScope(userId) } },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function changeOwnPassword(
|
||||
actor: { id: string; sessionVersion: number },
|
||||
raw: unknown,
|
||||
) {
|
||||
const schema = newPasswordSchema.and(
|
||||
z.object({
|
||||
currentPassword: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(value) => Buffer.byteLength(value, "utf8") <= 72,
|
||||
"Aktuelles Passwort ist ungültig.",
|
||||
),
|
||||
}),
|
||||
);
|
||||
const input = schema.safeParse(raw);
|
||||
if (!input.success)
|
||||
throw new UserFacingError(
|
||||
input.error.issues[0]?.message ?? "Bitte die Eingaben prüfen.",
|
||||
);
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: actor.id,
|
||||
sessionVersion: actor.sessionVersion,
|
||||
disabledAt: null,
|
||||
emailVerifiedAt: { not: null },
|
||||
},
|
||||
});
|
||||
if (!user || !(await compare(input.data.currentPassword, user.passwordHash)))
|
||||
throw new UserFacingError(
|
||||
"Das aktuelle Passwort ist nicht korrekt. Bitte melde dich bei Bedarf erneut an.",
|
||||
);
|
||||
if (await compare(input.data.password, user.passwordHash))
|
||||
throw new UserFacingError("Bitte wähle ein anderes neues Passwort.");
|
||||
const passwordHash = await hash(input.data.password, 12);
|
||||
await serializableTransaction(async (tx) => {
|
||||
const changed = await tx.user.updateMany({
|
||||
where: {
|
||||
id: user.id,
|
||||
passwordHash: user.passwordHash,
|
||||
sessionVersion: actor.sessionVersion,
|
||||
disabledAt: null,
|
||||
emailVerifiedAt: { not: null },
|
||||
},
|
||||
data: {
|
||||
passwordHash,
|
||||
sessionVersion: { increment: 1 },
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
},
|
||||
});
|
||||
if (changed.count !== 1)
|
||||
throw new UserFacingError(
|
||||
"Das Konto wurde inzwischen geändert. Bitte melde dich erneut an.",
|
||||
);
|
||||
await tx.verificationToken.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ identifier: user.id },
|
||||
{ identifier: { startsWith: resetScope(user.id) } },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -40,25 +40,29 @@ export async function deliverStaffEmails(ids: string[], send = sendAppEmail) {
|
||||
},
|
||||
});
|
||||
const { user, profile } = job;
|
||||
let eligible = user.kitaId === profile.kitaId && !user.disabledAt && (!user.lockedUntil || user.lockedUntil <= now);
|
||||
let eligible =
|
||||
user.kitaId === profile.kitaId &&
|
||||
!user.disabledAt &&
|
||||
(!user.lockedUntil || user.lockedUntil <= now);
|
||||
if (job.inviteToken) {
|
||||
const token = await prisma.verificationToken.findUnique({
|
||||
where: { token: hashToken(job.inviteToken) },
|
||||
});
|
||||
eligible &&=
|
||||
profile.userId === user.id &&
|
||||
user.role === "ERZIEHER" &&
|
||||
["ERZIEHER", "ADMIN"].includes(user.role) &&
|
||||
!user.emailVerifiedAt &&
|
||||
!!token &&
|
||||
token.identifier === user.id &&
|
||||
token.expires > now;
|
||||
} else {
|
||||
eligible &&=
|
||||
!!user.emailVerifiedAt && !!user.privacyPolicyAcceptedAt &&
|
||||
!!user.emailVerifiedAt &&
|
||||
!!user.privacyPolicyAcceptedAt &&
|
||||
(!user.lockedUntil || user.lockedUntil <= now) &&
|
||||
(job.toAdmins
|
||||
? user.role === "ADMIN"
|
||||
: user.role === "ERZIEHER" &&
|
||||
: ["ERZIEHER", "ADMIN"].includes(user.role) &&
|
||||
profile.userId === user.id &&
|
||||
user.staffDecisionEmails);
|
||||
}
|
||||
|
||||
+46
-15
@@ -81,7 +81,11 @@ async function inviteStaff(
|
||||
if (
|
||||
existingUser &&
|
||||
(existingUser.kitaId !== actor.kitaId ||
|
||||
existingUser.role !== UserRole.ERZIEHER ||
|
||||
!(
|
||||
existingUser.role === UserRole.ERZIEHER ||
|
||||
(existingUser.role === UserRole.ADMIN &&
|
||||
existingUser.id === profile.userId)
|
||||
) ||
|
||||
(await tx.staffProfile.count({
|
||||
where: { userId: existingUser.id, id: { not: profile.id } },
|
||||
})))
|
||||
@@ -205,7 +209,9 @@ export function assertStaffAccess(
|
||||
throw new UserFacingError("Kein Zugriff auf dieses Personalprofil.");
|
||||
if (adminOnly) requireAdmin(actor);
|
||||
else if (actor.role !== UserRole.ADMIN && profile.userId !== actor.id)
|
||||
throw new UserFacingError("Du kannst nur dein eigenes Personalprofil verwalten.");
|
||||
throw new UserFacingError(
|
||||
"Du kannst nur dein eigenes Personalprofil verwalten.",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getStaffProfile(actor: StaffActor, profileId?: string) {
|
||||
@@ -419,7 +425,9 @@ export async function executeStaffCommand(
|
||||
if (
|
||||
profile.user &&
|
||||
(profile.user.kitaId !== actor.kitaId ||
|
||||
profile.user.role !== UserRole.ERZIEHER)
|
||||
!([UserRole.ERZIEHER, UserRole.ADMIN] as UserRole[]).includes(
|
||||
profile.user.role,
|
||||
))
|
||||
)
|
||||
throw new UserFacingError(
|
||||
"Der verknüpfte Account gehört nicht zum Fachpersonal dieser Kita.",
|
||||
@@ -440,7 +448,11 @@ export async function executeStaffCommand(
|
||||
return { ...invitation, educatorId: educator.id };
|
||||
}
|
||||
if (command === "emailPreference") {
|
||||
if (actor.role !== UserRole.ERZIEHER)
|
||||
if (
|
||||
!(await tx.staffProfile.count({
|
||||
where: { userId: actor.id, kitaId: actor.kitaId },
|
||||
}))
|
||||
)
|
||||
throw new UserFacingError(
|
||||
"Diese Einstellung ist für Fachkräfte vorgesehen.",
|
||||
);
|
||||
@@ -534,7 +546,9 @@ export async function executeStaffCommand(
|
||||
input.employmentEnd &&
|
||||
input.employmentEnd < input.employmentStart
|
||||
)
|
||||
throw new UserFacingError("Das Beschäftigungsende liegt vor dem Beginn.");
|
||||
throw new UserFacingError(
|
||||
"Das Beschäftigungsende liegt vor dem Beginn.",
|
||||
);
|
||||
if (
|
||||
input.trackingStart < input.employmentStart ||
|
||||
(input.employmentEnd && input.trackingStart > input.employmentEnd)
|
||||
@@ -545,7 +559,8 @@ export async function executeStaffCommand(
|
||||
const educator = await tx.educator.findFirst({
|
||||
where: { id: input.educatorId, kitaId: actor.kitaId },
|
||||
});
|
||||
if (!educator) throw new UserFacingError("Fachkraft nicht gefunden.");
|
||||
if (!educator)
|
||||
throw new UserFacingError("Fachkraft nicht gefunden.");
|
||||
const existing = await tx.staffProfile.findFirst({
|
||||
where: { educatorId: educator.id, kitaId: actor.kitaId },
|
||||
include: profileInclude,
|
||||
@@ -591,7 +606,8 @@ export async function executeStaffCommand(
|
||||
where: { id: profileId, kitaId: actor.kitaId },
|
||||
include: profileInclude,
|
||||
});
|
||||
if (!profile) throw new UserFacingError("Personalprofil nicht gefunden.");
|
||||
if (!profile)
|
||||
throw new UserFacingError("Personalprofil nicht gefunden.");
|
||||
assertStaffAccess(actor, profile);
|
||||
const holidays = new Set(
|
||||
(
|
||||
@@ -631,7 +647,10 @@ export async function executeStaffCommand(
|
||||
for (const user of recipients) {
|
||||
if (
|
||||
!toAdmins &&
|
||||
(user.role !== UserRole.ERZIEHER || !user.staffDecisionEmails)
|
||||
(!(
|
||||
[UserRole.ERZIEHER, UserRole.ADMIN] as UserRole[]
|
||||
).includes(user.role) ||
|
||||
!user.staffDecisionEmails)
|
||||
)
|
||||
continue;
|
||||
const job = await tx.staffEmail.create({
|
||||
@@ -719,7 +738,9 @@ export async function executeStaffCommand(
|
||||
input.employmentEnd &&
|
||||
input.employmentEnd < dateKey(profile.employmentStart)
|
||||
)
|
||||
throw new UserFacingError("Das Beschäftigungsende liegt vor dem Beginn.");
|
||||
throw new UserFacingError(
|
||||
"Das Beschäftigungsende liegt vor dem Beginn.",
|
||||
);
|
||||
if (
|
||||
input.employmentEnd &&
|
||||
(input.employmentEnd < dateKey(profile.trackingStart) ||
|
||||
@@ -786,7 +807,9 @@ export async function executeStaffCommand(
|
||||
(profile.employmentEnd &&
|
||||
input.date > dateKey(profile.employmentEnd))
|
||||
)
|
||||
throw new UserFacingError("Der Tag liegt außerhalb der Beschäftigung.");
|
||||
throw new UserFacingError(
|
||||
"Der Tag liegt außerhalb der Beschäftigung.",
|
||||
);
|
||||
if (input.remove)
|
||||
await tx.staffDayOverride.deleteMany({
|
||||
where: { profileId, date: dateOnly(input.date) },
|
||||
@@ -859,7 +882,9 @@ export async function executeStaffCommand(
|
||||
})
|
||||
.parse(data);
|
||||
if (input.date < dateKey(profile.trackingStart))
|
||||
throw new UserFacingError("Korrekturen müssen ab Erfassungsbeginn liegen.");
|
||||
throw new UserFacingError(
|
||||
"Korrekturen müssen ab Erfassungsbeginn liegen.",
|
||||
);
|
||||
await tx.staffAdjustment.create({
|
||||
data: {
|
||||
profileId,
|
||||
@@ -887,7 +912,8 @@ export async function executeStaffCommand(
|
||||
throw new UserFacingError("Zeiteintrag nicht gefunden.");
|
||||
if (existing) ensureDayEditable(profile, dateKey(existing.date));
|
||||
if (command === "deleteTime") {
|
||||
if (!existing) throw new UserFacingError("Zeiteintrag nicht gefunden.");
|
||||
if (!existing)
|
||||
throw new UserFacingError("Zeiteintrag nicht gefunden.");
|
||||
await tx.staffTimeEntry.delete({ where: { id: existing.id } });
|
||||
await history(
|
||||
"Arbeitszeit gelöscht",
|
||||
@@ -1135,7 +1161,8 @@ export async function executeStaffCommand(
|
||||
const existing = data.id
|
||||
? profile.leaves.find((l) => l.id === idSchema.parse(data.id))
|
||||
: undefined;
|
||||
if (data.id && !existing) throw new UserFacingError("Antrag nicht gefunden.");
|
||||
if (data.id && !existing)
|
||||
throw new UserFacingError("Antrag nicht gefunden.");
|
||||
if (command === "leaveCancel") {
|
||||
if (
|
||||
!existing ||
|
||||
@@ -1182,7 +1209,9 @@ export async function executeStaffCommand(
|
||||
!existing ||
|
||||
!["PENDING", "CANCEL_REQUESTED"].includes(existing.status)
|
||||
)
|
||||
throw new UserFacingError("Dieser Antrag ist nicht mehr offen.");
|
||||
throw new UserFacingError(
|
||||
"Dieser Antrag ist nicht mehr offen.",
|
||||
);
|
||||
const cancelling = existing.status === "CANCEL_REQUESTED";
|
||||
if (input.approve)
|
||||
ensureRangeEditable(
|
||||
@@ -1284,7 +1313,9 @@ export async function executeStaffCommand(
|
||||
.parse(data);
|
||||
if (input.kind === "SICKNESS") requireAdmin(actor);
|
||||
if (existing && existing.status !== "PENDING")
|
||||
throw new UserFacingError("Nur offene Anträge können bearbeitet werden.");
|
||||
throw new UserFacingError(
|
||||
"Nur offene Anträge können bearbeitet werden.",
|
||||
);
|
||||
if (input.startDate < dateKey(profile.trackingStart))
|
||||
throw new UserFacingError(
|
||||
"Abwesenheiten bitte ab Erfassungsbeginn erfassen.",
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports -- Test real server code with isolated request and transport boundaries. */
|
||||
const { test, before, after, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const ts = require("typescript");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
let prisma,
|
||||
current,
|
||||
f,
|
||||
change,
|
||||
changeAction,
|
||||
remove,
|
||||
support,
|
||||
updateFamily,
|
||||
alarms,
|
||||
alarmMail,
|
||||
staff,
|
||||
staffMail,
|
||||
assign;
|
||||
const emails = [];
|
||||
const tenants = [],
|
||||
looseUsers = [];
|
||||
function loader(overrides = {}) {
|
||||
const cache = new Map();
|
||||
function load(file) {
|
||||
file = path.resolve(file);
|
||||
if (cache.has(file)) return cache.get(file).exports;
|
||||
const mod = { exports: {} };
|
||||
cache.set(file, mod);
|
||||
const code = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
fileName: file,
|
||||
}).outputText;
|
||||
const req = (spec) => {
|
||||
if (Object.hasOwn(overrides, spec)) return overrides[spec];
|
||||
if (spec === "server-only") return {};
|
||||
if (spec === "@/lib/prisma") return { prisma };
|
||||
if (spec === "next/headers")
|
||||
return { headers: async () => new Headers() };
|
||||
if (spec === "next/cache") return { revalidatePath() {} };
|
||||
if (spec === "next/navigation")
|
||||
return {
|
||||
redirect(url) {
|
||||
throw new Error(`REDIRECT:${url}`);
|
||||
},
|
||||
notFound() {
|
||||
throw new Error("NOT_FOUND");
|
||||
},
|
||||
};
|
||||
if (spec.startsWith("@/") || spec.startsWith(".")) {
|
||||
const target = spec.startsWith("@/")
|
||||
? path.resolve("src", spec.slice(2))
|
||||
: path.resolve(path.dirname(file), spec);
|
||||
for (const suffix of [".ts", ".tsx", "/index.ts"])
|
||||
if (fs.existsSync(target + suffix)) return load(target + suffix);
|
||||
}
|
||||
return require(spec);
|
||||
};
|
||||
vm.runInThisContext(`(function(require,module,exports){${code}\n})`, {
|
||||
filename: file,
|
||||
})(req, mod, mod.exports);
|
||||
return mod.exports;
|
||||
}
|
||||
return load;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
const raw = process.env.TEST_DATABASE_URL;
|
||||
assert.ok(
|
||||
raw,
|
||||
"TEST_DATABASE_URL muss ausdrücklich auf die separate lokale kita_roles_test zeigen.",
|
||||
);
|
||||
const url = new URL(raw);
|
||||
assert.ok(["127.0.0.1", "localhost"].includes(url.hostname));
|
||||
assert.equal(url.pathname, "/kita_roles_test");
|
||||
prisma = new PrismaClient({ datasourceUrl: raw });
|
||||
const load = loader({
|
||||
"@/lib/auth-utils": {
|
||||
requireKitaSession: async () => ({ user: current }),
|
||||
requireRole: async (roles) => {
|
||||
assert.ok(roles.includes(current.role));
|
||||
return { user: current };
|
||||
},
|
||||
},
|
||||
"@/lib/mail": {
|
||||
getAppEmailConfigError: () => null,
|
||||
sendAppEmail: async (input) => {
|
||||
emails.push(input);
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
});
|
||||
change = load("src/lib/kita-role-management.ts").changeKitaRole;
|
||||
changeAction = load(
|
||||
"src/app/dashboard/zugriffsrechte/actions.ts",
|
||||
).changeKitaRoleAction;
|
||||
remove = load("src/app/dashboard/profil/actions.ts").deleteMyAccount;
|
||||
support = load("src/app/admin/actions.ts");
|
||||
updateFamily = load(
|
||||
"src/app/dashboard/families/actions.ts",
|
||||
).updateFamilyAction;
|
||||
alarms = load("src/lib/alarms/service.ts");
|
||||
alarmMail = load("src/lib/alarms/email.ts");
|
||||
staff = load("src/lib/staff/service.ts");
|
||||
staffMail = load("src/lib/staff/email.ts");
|
||||
assign = load(
|
||||
"src/app/dashboard/notdienst/plan/actions.ts",
|
||||
).updateAssignmentAction;
|
||||
process.env.AUTH_URL = "http://127.0.0.1:3008";
|
||||
});
|
||||
async function user(role, extra = {}, kitaId = f.kita.id) {
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
kitaId,
|
||||
email: `${randomUUID()}@example.invalid`,
|
||||
firstName: "Test",
|
||||
lastName: role,
|
||||
passwordHash: "test-only",
|
||||
emailVerifiedAt: new Date(),
|
||||
privacyPolicyAcceptedAt: new Date(),
|
||||
role,
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
}
|
||||
const actor = (u) => ({
|
||||
id: u.id,
|
||||
kitaId: u.kitaId,
|
||||
sessionVersion: u.sessionVersion,
|
||||
});
|
||||
const changeTo = (u, role, by = f.admin) =>
|
||||
change(actor(by), { userId: u.id, role, expectedRole: u.role });
|
||||
beforeEach(async () => {
|
||||
const kita = await prisma.kita.create({
|
||||
data: { name: "Rollen-Test", slug: `roles-${randomUUID()}` },
|
||||
});
|
||||
tenants.push(kita.id);
|
||||
f = { kita };
|
||||
f.admin = await user("ADMIN");
|
||||
f.family = await prisma.family.create({
|
||||
data: { kitaId: kita.id, name: "Beispiel" },
|
||||
});
|
||||
f.parent = await user("ELTERN", { familyId: f.family.id });
|
||||
f.child = await prisma.child.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
familyId: f.family.id,
|
||||
firstName: "Kind",
|
||||
lastName: "Test",
|
||||
},
|
||||
});
|
||||
current = f.admin;
|
||||
emails.length = 0;
|
||||
});
|
||||
after(async () => {
|
||||
if (!prisma) return;
|
||||
await prisma.kita.deleteMany({ where: { id: { in: tenants } } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: looseUsers } } });
|
||||
await prisma.auditLog.deleteMany({
|
||||
where: { targetKitaId: { in: tenants } },
|
||||
});
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
test("Eltern werden Koordination und Kita-Admin; Haushalt und Kind bleiben erhalten", async () => {
|
||||
assert.equal(
|
||||
(
|
||||
await changeAction({
|
||||
userId: f.parent.id,
|
||||
expectedRole: "ELTERN",
|
||||
role: "KOORDINATOR",
|
||||
})
|
||||
).success,
|
||||
true,
|
||||
);
|
||||
const coord = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: f.parent.id },
|
||||
});
|
||||
await changeTo(coord, "ADMIN");
|
||||
const updated = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: coord.id },
|
||||
});
|
||||
assert.equal(updated.familyId, f.family.id);
|
||||
assert.equal(updated.sessionVersion, coord.sessionVersion);
|
||||
assert.equal(
|
||||
await prisma.child.count({ where: { familyId: f.family.id } }),
|
||||
1,
|
||||
);
|
||||
});
|
||||
test("Rollenänderung protokolliert Akteur, Kita und vorherige/neue Rolle genau einmal", async () => {
|
||||
await changeTo(f.parent, "ADMIN");
|
||||
const jobs = await prisma.auditLog.findMany({
|
||||
where: { targetKitaId: f.kita.id, action: "USER_ROLE_CHANGE" },
|
||||
});
|
||||
assert.equal(jobs.length, 1);
|
||||
assert.equal(jobs[0].actorUserId, f.admin.id);
|
||||
assert.deepEqual(jobs[0].metadata, {
|
||||
previousRole: "ELTERN",
|
||||
newRole: "ADMIN",
|
||||
});
|
||||
assert.equal(
|
||||
(await changeTo({ ...f.parent, role: "ADMIN" }, "ADMIN")).changed,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
await prisma.auditLog.count({
|
||||
where: { targetKitaId: f.kita.id, action: "USER_ROLE_CHANGE" },
|
||||
}),
|
||||
1,
|
||||
);
|
||||
});
|
||||
test("Konto ohne Haushalt darf Koordination werden, aber nicht Eltern oder Fachkraft", async () => {
|
||||
const person = await user("KOORDINATOR");
|
||||
await assert.rejects(changeTo(person, "ELTERN"), /Haushalt/);
|
||||
await assert.rejects(changeTo(person, "ERZIEHER"), /Personalprofil/);
|
||||
assert.equal((await changeTo(person, "ADMIN")).changed, true);
|
||||
});
|
||||
test("Superadmin-Rolle kann weder vergeben noch verändert werden", async () => {
|
||||
await assert.rejects(changeTo(f.parent, "SUPERADMIN"), /Ungültige/);
|
||||
const root = await user("SUPERADMIN");
|
||||
await assert.rejects(
|
||||
change(actor(f.admin), {
|
||||
userId: root.id,
|
||||
expectedRole: "ADMIN",
|
||||
role: "KOORDINATOR",
|
||||
}),
|
||||
/nicht geändert/,
|
||||
);
|
||||
});
|
||||
test("Fremde Kita ist bei Zielkonto und Akteur ausgeschlossen", async () => {
|
||||
const foreign = await prisma.kita.create({
|
||||
data: { name: "Andere", slug: `roles-${randomUUID()}` },
|
||||
});
|
||||
tenants.push(foreign.id);
|
||||
const outsider = await user("ADMIN", {}, foreign.id);
|
||||
await assert.rejects(changeTo(outsider, "KOORDINATOR"), /nicht geändert/);
|
||||
await assert.rejects(
|
||||
change(
|
||||
{ ...actor(f.admin), kitaId: foreign.id },
|
||||
{ userId: outsider.id, expectedRole: "ADMIN", role: "KOORDINATOR" },
|
||||
),
|
||||
/nicht mehr ändern/,
|
||||
);
|
||||
});
|
||||
test("Eltern, gesperrte Admins und veraltete Sessions dürfen keine Rollen ändern", async () => {
|
||||
await assert.rejects(
|
||||
change(actor(f.parent), {
|
||||
userId: f.admin.id,
|
||||
expectedRole: "ADMIN",
|
||||
role: "KOORDINATOR",
|
||||
}),
|
||||
/nicht mehr ändern/,
|
||||
);
|
||||
await assert.rejects(
|
||||
change(
|
||||
{ ...actor(f.admin), sessionVersion: 9 },
|
||||
{ userId: f.parent.id, expectedRole: "ELTERN", role: "ADMIN" },
|
||||
),
|
||||
/nicht mehr ändern/,
|
||||
);
|
||||
await prisma.user.update({
|
||||
where: { id: f.admin.id },
|
||||
data: { disabledAt: new Date() },
|
||||
});
|
||||
await assert.rejects(changeTo(f.parent, "ADMIN"), /nicht mehr ändern/);
|
||||
});
|
||||
test("Veralteter Dialog überschreibt keine zwischenzeitliche Rollenänderung", async () => {
|
||||
await changeTo(f.parent, "KOORDINATOR");
|
||||
await assert.rejects(changeTo(f.parent, "ADMIN"), /inzwischen geändert/);
|
||||
assert.equal(
|
||||
(await prisma.user.findUnique({ where: { id: f.parent.id } })).role,
|
||||
"KOORDINATOR",
|
||||
);
|
||||
});
|
||||
test("Letzter Admin kann seine Rolle nicht abgeben", async () => {
|
||||
await assert.rejects(
|
||||
changeTo(f.admin, "KOORDINATOR"),
|
||||
/Mindestens ein aktiver/,
|
||||
);
|
||||
});
|
||||
for (const [label, extra] of [
|
||||
["offene Einladung", { emailVerifiedAt: null, passwordHash: "" }],
|
||||
["deaktiviert", { disabledAt: new Date() }],
|
||||
["gesperrt", { lockedUntil: new Date("2099-01-01") }],
|
||||
["ohne Zustimmung", { privacyPolicyAcceptedAt: null }],
|
||||
]) {
|
||||
test(`Ein weiterer Admin ${label} reicht nicht als Ersatz`, async () => {
|
||||
await user("ADMIN", extra);
|
||||
await assert.rejects(
|
||||
changeTo(f.admin, "KOORDINATOR"),
|
||||
/Mindestens ein aktiver/,
|
||||
);
|
||||
});
|
||||
}
|
||||
test("Mit zweitem aktivierten Admin ist eigene Herabstufung möglich und stale Admin-Rechte sind unwirksam", async () => {
|
||||
await user("ADMIN");
|
||||
assert.equal((await changeTo(f.admin, "KOORDINATOR")).ownRoleChanged, true);
|
||||
await assert.rejects(changeTo(f.parent, "ADMIN"), /nicht mehr ändern/);
|
||||
});
|
||||
test("Parallele Herabstufung zweier Admins lässt genau einen Admin bestehen", async () => {
|
||||
const second = await user("ADMIN");
|
||||
const results = await Promise.allSettled([
|
||||
changeTo(f.admin, "KOORDINATOR", f.admin),
|
||||
changeTo(second, "KOORDINATOR", second),
|
||||
]);
|
||||
assert.equal(results.filter((r) => r.status === "fulfilled").length, 1);
|
||||
assert.equal(
|
||||
await prisma.user.count({ where: { kitaId: f.kita.id, role: "ADMIN" } }),
|
||||
1,
|
||||
);
|
||||
});
|
||||
test("Eigene Kontolöschung ist für letzten Admin gesperrt", async () => {
|
||||
assert.match((await remove()).error, /Mindestens ein aktiver/);
|
||||
assert.ok(await prisma.user.findUnique({ where: { id: f.admin.id } }));
|
||||
});
|
||||
test("Eigene Kontolöschung mit zweitem aktivem Admin funktioniert weiterhin", async () => {
|
||||
await user("ADMIN");
|
||||
assert.equal((await remove()).success, true);
|
||||
assert.equal(
|
||||
await prisma.user.findUnique({ where: { id: f.admin.id } }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
test("Superadmin kann den letzten Kita-Admin weder löschen noch sperren", async () => {
|
||||
current = await user("SUPERADMIN", {}, null);
|
||||
looseUsers.push(current.id);
|
||||
current.authenticatedAt = Date.now();
|
||||
assert.match(
|
||||
(
|
||||
await support.deleteUser({
|
||||
userId: f.admin.id,
|
||||
confirmationEmail: f.admin.email,
|
||||
})
|
||||
).error,
|
||||
/Mindestens ein aktiver/,
|
||||
);
|
||||
assert.match(
|
||||
(await support.lockUser(f.admin.id)).error,
|
||||
/Mindestens ein aktiver/,
|
||||
);
|
||||
});
|
||||
test("Support darf zusätzlichen Admin sperren und löschen", async () => {
|
||||
const second = await user("ADMIN");
|
||||
current = await user("SUPERADMIN", {}, null);
|
||||
looseUsers.push(current.id);
|
||||
current.authenticatedAt = Date.now();
|
||||
assert.equal((await support.lockUser(second.id)).ok, true);
|
||||
assert.equal(
|
||||
(
|
||||
await support.deleteUser({
|
||||
userId: second.id,
|
||||
confirmationEmail: second.email,
|
||||
})
|
||||
).ok,
|
||||
true,
|
||||
);
|
||||
});
|
||||
test("Gleichzeitige Admin-Löschung und Herabstufung lässt einen Admin bestehen", async () => {
|
||||
const second = await user("ADMIN");
|
||||
const results = await Promise.allSettled([
|
||||
remove(),
|
||||
changeTo(second, "KOORDINATOR"),
|
||||
]);
|
||||
const successful =
|
||||
results.filter((r) => r.status === "fulfilled" && r.value.success).length +
|
||||
results.filter((r) => r.status === "fulfilled" && r.value.changed).length;
|
||||
assert.equal(successful, 1);
|
||||
assert.equal(
|
||||
await prisma.user.count({ where: { kitaId: f.kita.id, role: "ADMIN" } }),
|
||||
1,
|
||||
);
|
||||
});
|
||||
test("E-Mail-Änderung darf letzten aktivierten Admin nicht deaktivieren", async () => {
|
||||
await prisma.user.update({
|
||||
where: { id: f.admin.id },
|
||||
data: { familyId: f.family.id },
|
||||
});
|
||||
const payload = {
|
||||
familyId: f.family.id,
|
||||
familyName: f.family.name,
|
||||
parents: [
|
||||
f.parent,
|
||||
{ ...f.admin, email: `${randomUUID()}@example.invalid` },
|
||||
],
|
||||
children: [f.child],
|
||||
};
|
||||
assert.match((await updateFamily(payload)).error, /Mindestens ein aktiver/);
|
||||
assert.equal(
|
||||
(await prisma.user.findUnique({ where: { id: f.admin.id } })).email,
|
||||
f.admin.email,
|
||||
);
|
||||
});
|
||||
|
||||
test("Fachkraft wird Admin; Personalprofil bleibt bearbeitbar und eigene Mailpräferenz erhalten", async () => {
|
||||
const person = await user("ERZIEHER");
|
||||
const educator = await prisma.educator.create({
|
||||
data: { kitaId: f.kita.id, firstName: "Team", lastName: "Test" },
|
||||
});
|
||||
const profile = await prisma.staffProfile.create({
|
||||
data: {
|
||||
kitaId: f.kita.id,
|
||||
educatorId: educator.id,
|
||||
userId: person.id,
|
||||
employmentStart: new Date("2030-01-01"),
|
||||
trackingStart: new Date("2030-01-01"),
|
||||
},
|
||||
});
|
||||
await assert.rejects(changeTo(person, "KOORDINATOR"), /Personalprofil/);
|
||||
await changeTo(person, "ADMIN");
|
||||
await staff.executeStaffCommand(
|
||||
{ id: person.id, kitaId: f.kita.id, role: "ADMIN" },
|
||||
{ command: "emailPreference", data: { enabled: false } },
|
||||
);
|
||||
await staff.executeStaffCommand(
|
||||
{ id: f.admin.id, kitaId: f.kita.id, role: "ADMIN" },
|
||||
{
|
||||
command: "educatorUpdate",
|
||||
data: {
|
||||
educatorId: educator.id,
|
||||
firstName: "Team",
|
||||
lastName: "Geändert",
|
||||
active: true,
|
||||
email: person.email,
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
(await prisma.staffProfile.findUnique({ where: { id: profile.id } }))
|
||||
.userId,
|
||||
person.id,
|
||||
);
|
||||
assert.equal(
|
||||
(await prisma.user.findUnique({ where: { id: person.id } }))
|
||||
.staffDecisionEmails,
|
||||
false,
|
||||
);
|
||||
await changeTo({ ...person, role: "ADMIN" }, "ERZIEHER");
|
||||
});
|
||||
test("Offene Fachkraft-Einladung lässt sich auch nach Admin-Ernennung erneut senden", async () => {
|
||||
const person = await user("ERZIEHER", {
|
||||
emailVerifiedAt: null,
|
||||
passwordHash: "",
|
||||
});
|
||||
const educator = await prisma.educator.create({
|
||||
data: { kitaId: f.kita.id, firstName: "Team", lastName: "Test" },
|
||||
});
|
||||
await prisma.staffProfile.create({
|
||||
data: {
|
||||
kitaId: f.kita.id,
|
||||
educatorId: educator.id,
|
||||
userId: person.id,
|
||||
employmentStart: new Date("2030-01-01"),
|
||||
trackingStart: new Date("2030-01-01"),
|
||||
},
|
||||
});
|
||||
await changeTo(person, "ADMIN");
|
||||
const queued = await staff.executeStaffCommand(
|
||||
{ id: f.admin.id, kitaId: f.kita.id, role: "ADMIN" },
|
||||
{ command: "educatorInvite", data: { educatorId: educator.id } },
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await staffMail.deliverStaffEmails(queued.emailIds, async () => ({
|
||||
success: true,
|
||||
}))
|
||||
).sent,
|
||||
1,
|
||||
);
|
||||
});
|
||||
for (const role of ["ADMIN", "KOORDINATOR"])
|
||||
test(`Haushaltsalarme erreichen und akzeptieren Eltern mit Rolle ${role}`, async () => {
|
||||
await changeTo(f.parent, role);
|
||||
const plan = await prisma.notdienstPlan.create({
|
||||
data: {
|
||||
kitaId: f.kita.id,
|
||||
year: 2030,
|
||||
month: 11,
|
||||
status: "PUBLISHED",
|
||||
assignments: {
|
||||
create: {
|
||||
kitaId: f.kita.id,
|
||||
childId: f.child.id,
|
||||
date: new Date("2030-11-04"),
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { assignments: true },
|
||||
});
|
||||
const job = await alarms.executeAlarmCommand(
|
||||
{ id: f.admin.id, kitaId: f.kita.id, role: "ADMIN" },
|
||||
"create",
|
||||
{ assignmentId: plan.assignments[0].id, wholeDay: true },
|
||||
);
|
||||
const request = await prisma.notdienstAlarmEmail.findFirstOrThrow({
|
||||
where: { alarmId: job.alarmId, userId: f.parent.id, kind: "REQUEST" },
|
||||
});
|
||||
const token = request.path.split("/").pop();
|
||||
assert.ok(await alarms.getPublicAlarm(token));
|
||||
assert.equal(
|
||||
(
|
||||
await alarmMail.deliverAlarmEmails([request.id], async () => ({
|
||||
success: true,
|
||||
}))
|
||||
).sent,
|
||||
1,
|
||||
);
|
||||
await alarms.respondToAlarm(token, true);
|
||||
assert.equal(
|
||||
(await prisma.notdienstAlarm.findUnique({ where: { id: job.alarmId } }))
|
||||
.status,
|
||||
"CONFIRMED",
|
||||
);
|
||||
});
|
||||
test("Bereits ausgestellter Haushalts-Alarmlink bleibt nach Ernennung gültig", async () => {
|
||||
const plan = await prisma.notdienstPlan.create({
|
||||
data: {
|
||||
kitaId: f.kita.id,
|
||||
year: 2030,
|
||||
month: 11,
|
||||
status: "PUBLISHED",
|
||||
assignments: {
|
||||
create: {
|
||||
kitaId: f.kita.id,
|
||||
childId: f.child.id,
|
||||
date: new Date("2030-11-04"),
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { assignments: true },
|
||||
});
|
||||
const job = await alarms.executeAlarmCommand(
|
||||
{ id: f.admin.id, kitaId: f.kita.id, role: "ADMIN" },
|
||||
"create",
|
||||
{ assignmentId: plan.assignments[0].id, wholeDay: true },
|
||||
);
|
||||
const request = await prisma.notdienstAlarmEmail.findFirstOrThrow({
|
||||
where: { alarmId: job.alarmId, userId: f.parent.id, kind: "REQUEST" },
|
||||
});
|
||||
await changeTo(f.parent, "ADMIN");
|
||||
assert.ok(await alarms.getPublicAlarm(request.path.split("/").pop()));
|
||||
});
|
||||
|
||||
test("Letzter Admin im Ein-Personen-Haushalt bleibt auch vor kaskadierender Familienlöschung geschützt", async () => {
|
||||
await prisma.user.update({
|
||||
where: { id: f.parent.id },
|
||||
data: { role: "ADMIN" },
|
||||
});
|
||||
await prisma.user.update({
|
||||
where: { id: f.admin.id },
|
||||
data: { role: "KOORDINATOR" },
|
||||
});
|
||||
current = { ...f.parent, role: "ADMIN" };
|
||||
assert.match((await remove()).error, /Mindestens ein aktiver/);
|
||||
assert.ok(await prisma.family.findUnique({ where: { id: f.family.id } }));
|
||||
assert.ok(await prisma.child.findUnique({ where: { id: f.child.id } }));
|
||||
});
|
||||
|
||||
test("Veröffentlichte Umteilung informiert auch den Admin im betroffenen Elternhaushalt", async () => {
|
||||
await changeTo(f.parent, "ADMIN");
|
||||
const family = await prisma.family.create({
|
||||
data: { kitaId: f.kita.id, name: "Ersatz" },
|
||||
});
|
||||
const parent = await user("ELTERN", { familyId: family.id });
|
||||
const child = await prisma.child.create({
|
||||
data: {
|
||||
kitaId: f.kita.id,
|
||||
familyId: family.id,
|
||||
firstName: "Ersatz",
|
||||
lastName: "Test",
|
||||
},
|
||||
});
|
||||
const plan = await prisma.notdienstPlan.create({
|
||||
data: {
|
||||
kitaId: f.kita.id,
|
||||
year: 2030,
|
||||
month: 11,
|
||||
status: "PUBLISHED",
|
||||
assignments: {
|
||||
create: {
|
||||
kitaId: f.kita.id,
|
||||
childId: f.child.id,
|
||||
date: new Date("2030-11-04"),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await assign(plan.id, "2030-11-04", child.id);
|
||||
assert.equal(result.success, true);
|
||||
assert.ok(emails.some((email) => [email.to].flat().includes(f.parent.email)));
|
||||
assert.ok(emails.some((email) => [email.to].flat().includes(parent.email)));
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports -- Render the actual client component through the TypeScript loader. */
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const ts = require("typescript");
|
||||
const React = require("react");
|
||||
const { renderToStaticMarkup } = require("react-dom/server");
|
||||
function loader(overrides = {}) {
|
||||
const cache = new Map();
|
||||
function load(file) {
|
||||
file = path.resolve(file);
|
||||
if (cache.has(file)) return cache.get(file).exports;
|
||||
const mod = { exports: {} };
|
||||
cache.set(file, mod);
|
||||
const code = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
fileName: file,
|
||||
}).outputText;
|
||||
const req = (spec) => {
|
||||
if (Object.hasOwn(overrides, spec)) return overrides[spec];
|
||||
if (spec === "server-only") return {};
|
||||
if (spec === "@/lib/prisma")
|
||||
throw new Error("No database in component render tests");
|
||||
if (spec === "next/headers")
|
||||
return { headers: async () => new Headers() };
|
||||
if (spec === "next/cache") return { revalidatePath() {} };
|
||||
if (spec === "next/navigation")
|
||||
return {
|
||||
redirect(url) {
|
||||
throw new Error(`REDIRECT:${url}`);
|
||||
},
|
||||
notFound() {
|
||||
throw new Error("NOT_FOUND");
|
||||
},
|
||||
};
|
||||
if (spec.startsWith("@/") || spec.startsWith(".")) {
|
||||
const target = spec.startsWith("@/")
|
||||
? path.resolve("src", spec.slice(2))
|
||||
: path.resolve(path.dirname(file), spec);
|
||||
for (const suffix of [".ts", ".tsx", "/index.ts"])
|
||||
if (fs.existsSync(target + suffix)) return load(target + suffix);
|
||||
}
|
||||
return require(spec);
|
||||
};
|
||||
vm.runInThisContext(`(function(require,module,exports){${code}\n})`, {
|
||||
filename: file,
|
||||
})(req, mod, mod.exports);
|
||||
return mod.exports;
|
||||
}
|
||||
return load;
|
||||
}
|
||||
|
||||
const load = loader({ "./actions": {} });
|
||||
const { ManualAssignmentTable } = load(
|
||||
"src/app/dashboard/notdienst/plan/plan-view.tsx",
|
||||
);
|
||||
const children = [
|
||||
{ id: "a", name: "Alex", parentName: "Haushalt A", familyId: "family-a" },
|
||||
{ id: "b", name: "Ben", parentName: "Haushalt B", familyId: "family-b" },
|
||||
{ id: "a2", name: "Anna", parentName: "Haushalt A", familyId: "family-a" },
|
||||
];
|
||||
const day = {
|
||||
date: "2030-11-04T00:00:00.000Z",
|
||||
assignmentId: null,
|
||||
childId: null,
|
||||
};
|
||||
const familyStatuses = [
|
||||
{ id: "family-a", selectedDays: [{ iso: day.date }] },
|
||||
{ id: "family-b", selectedDays: [{ iso: "2030-11-05T00:00:00.000Z" }] },
|
||||
];
|
||||
const render = (overrides = {}) =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(ManualAssignmentTable, {
|
||||
days: [day],
|
||||
allChildren: children,
|
||||
familyStatuses,
|
||||
isDraft: true,
|
||||
isPending: false,
|
||||
onAssignmentChange() {},
|
||||
...overrides,
|
||||
}),
|
||||
);
|
||||
test("Verfügbare Haushalte stehen zuerst; Geschwister zählen als eine verfügbare Familie", () => {
|
||||
const html = render();
|
||||
assert.ok(
|
||||
html.indexOf("Verfügbar an diesem Tag") <
|
||||
html.indexOf("Keine Verfügbarkeit für diesen Tag"),
|
||||
);
|
||||
assert.match(html, /1 Familie ist verfügbar/);
|
||||
assert.match(html, /value="a2"/);
|
||||
assert.match(html, /value="b"/);
|
||||
assert.match(html, /aria-label="Familie für 04.11.2030"/);
|
||||
});
|
||||
test("Bestehende Einteilung ohne Rückmeldung bleibt auswählbar und wird kenntlich gemacht", () => {
|
||||
const html = render({ days: [{ ...day, childId: "b" }] });
|
||||
assert.match(html, /value="b" selected/);
|
||||
assert.match(
|
||||
html,
|
||||
/Eingeteilte Familie hat für diesen Tag keine Verfügbarkeit angegeben/,
|
||||
);
|
||||
});
|
||||
test("Eingeteilte verfügbare Familie und Haushaltseinteilungszahl werden angezeigt", () => {
|
||||
const html = render({
|
||||
days: [
|
||||
{ ...day, childId: "a" },
|
||||
{ ...day, date: "2030-11-05T00:00:00.000Z", childId: "a2" },
|
||||
],
|
||||
});
|
||||
assert.match(html, /Eingeteilte Familie ist verfügbar/);
|
||||
assert.match(html, /Haushalt A \(Alex\) — 2x/);
|
||||
});
|
||||
test("Fehlende Verfügbarkeiten erzeugen eine klare Angabe ohne Familien auszublenden", () => {
|
||||
const html = render({ familyStatuses: [] });
|
||||
assert.match(html, /0 Familien sind verfügbar/);
|
||||
assert.match(html, /value="a"/);
|
||||
assert.match(html, /value="b"/);
|
||||
});
|
||||
test("Aktiver Alarm sperrt die Auswahl weiterhin", () => {
|
||||
const html = render({
|
||||
days: [{ ...day, childId: "a", activeAlarmId: "alarm" }],
|
||||
});
|
||||
assert.match(html, /<select[^>]*disabled/);
|
||||
});
|
||||
test("Ungültige alte Einteilung bleibt entfernbar; neue Kinder sind dafür gesperrt", () => {
|
||||
const html = render({
|
||||
days: [{ ...day, childId: "inactive", isInvalid: true }],
|
||||
});
|
||||
assert.match(html, /value="empty"/);
|
||||
assert.match(html, /value="inactive" disabled/);
|
||||
assert.match(html, /value="a" disabled/);
|
||||
assert.match(html, /Einteilung nicht mehr gültig/);
|
||||
});
|
||||
@@ -0,0 +1,584 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports -- Test the actual services/actions with request and transport boundaries replaced. */
|
||||
const { test, before, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const ts = require("typescript");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { hash, compare } = require("bcryptjs");
|
||||
const { render } = require("@react-email/render");
|
||||
let prisma, current, f, service, actions, tokenHash, initialHash, jwt;
|
||||
const tenants = [],
|
||||
emails = [],
|
||||
background = [];
|
||||
let transportFailure = false;
|
||||
const oldPassword = "Test-password-old-2026";
|
||||
const newPassword = "Test-password-new-2026";
|
||||
function loader(overrides = {}) {
|
||||
const cache = new Map();
|
||||
function load(file) {
|
||||
file = path.resolve(file);
|
||||
if (cache.has(file)) return cache.get(file).exports;
|
||||
const mod = { exports: {} };
|
||||
cache.set(file, mod);
|
||||
const code = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
fileName: file,
|
||||
}).outputText;
|
||||
const req = (spec) => {
|
||||
if (Object.hasOwn(overrides, spec)) return overrides[spec];
|
||||
if (spec === "server-only") return {};
|
||||
if (spec === "@/lib/prisma") return { prisma };
|
||||
if (spec === "next/headers")
|
||||
return { headers: async () => new Headers() };
|
||||
if (spec === "next/cache") return { revalidatePath() {} };
|
||||
if (spec === "next/navigation")
|
||||
return {
|
||||
redirect(url) {
|
||||
throw new Error(`REDIRECT:${url}`);
|
||||
},
|
||||
notFound() {
|
||||
throw new Error("NOT_FOUND");
|
||||
},
|
||||
};
|
||||
if (spec.startsWith("@/") || spec.startsWith(".")) {
|
||||
const target = spec.startsWith("@/")
|
||||
? path.resolve("src", spec.slice(2))
|
||||
: path.resolve(path.dirname(file), spec);
|
||||
for (const suffix of [".ts", ".tsx", "/index.ts"])
|
||||
if (fs.existsSync(target + suffix)) return load(target + suffix);
|
||||
}
|
||||
return require(spec);
|
||||
};
|
||||
vm.runInThisContext(`(function(require,module,exports){${code}\n})`, {
|
||||
filename: file,
|
||||
})(req, mod, mod.exports);
|
||||
return mod.exports;
|
||||
}
|
||||
return load;
|
||||
}
|
||||
|
||||
const form = (data) => {
|
||||
const f = new FormData();
|
||||
for (const [key, value] of Object.entries(data)) f.set(key, value);
|
||||
return f;
|
||||
};
|
||||
const token = () =>
|
||||
new URL(emails.at(-1).react.props.resetLink).pathname.split("/").pop();
|
||||
const resetInput = (link = token(), password = newPassword) => ({
|
||||
token: link,
|
||||
password,
|
||||
confirmation: password,
|
||||
});
|
||||
const drain = async () => {
|
||||
while (background.length) await background.shift()();
|
||||
};
|
||||
before(async () => {
|
||||
const raw = process.env.TEST_DATABASE_URL;
|
||||
assert.ok(
|
||||
raw,
|
||||
"TEST_DATABASE_URL muss ausdrücklich auf die lokale kita_account_test zeigen.",
|
||||
);
|
||||
const url = new URL(raw);
|
||||
assert.ok(["127.0.0.1", "localhost"].includes(url.hostname));
|
||||
assert.equal(url.pathname, "/kita_account_test");
|
||||
prisma = new PrismaClient({ datasourceUrl: raw });
|
||||
initialHash = await hash(oldPassword, 12);
|
||||
process.env.AUTH_URL = "http://127.0.0.1:3014";
|
||||
const load = loader({
|
||||
"@/lib/auth-utils": { requireSession: async () => ({ user: current }) },
|
||||
"@/lib/mail": {
|
||||
sendAppEmail: async (input) => {
|
||||
emails.push(input);
|
||||
return transportFailure
|
||||
? { success: false, error: "test-only" }
|
||||
: { success: true };
|
||||
},
|
||||
},
|
||||
"next/server": { after: (fn) => background.push(fn) },
|
||||
"next-auth": (config) => {
|
||||
jwt = config.callbacks.jwt;
|
||||
return {};
|
||||
},
|
||||
"next-auth/providers/credentials": (options) => options,
|
||||
});
|
||||
service = load("src/lib/password-management.ts");
|
||||
actions = load("src/app/passwort-vergessen/actions.ts");
|
||||
tokenHash = load("src/lib/tokens.ts").hashToken;
|
||||
load("src/auth.ts");
|
||||
});
|
||||
beforeEach(async () => {
|
||||
const kita = await prisma.kita.create({
|
||||
data: { name: "Kontotest", slug: "account-" + randomUUID() },
|
||||
});
|
||||
tenants.push(kita.id);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
email: randomUUID() + "@example.invalid",
|
||||
firstName: "Test",
|
||||
lastName: "Konto",
|
||||
passwordHash: initialHash,
|
||||
emailVerifiedAt: new Date(),
|
||||
privacyPolicyAcceptedAt: new Date(),
|
||||
},
|
||||
});
|
||||
f = { kita, user };
|
||||
current = user;
|
||||
emails.length = 0;
|
||||
background.length = 0;
|
||||
transportFailure = false;
|
||||
});
|
||||
after(async () => {
|
||||
if (!prisma) return;
|
||||
const users = await prisma.user.findMany({
|
||||
where: { kitaId: { in: tenants } },
|
||||
select: { id: true },
|
||||
});
|
||||
for (const u of users)
|
||||
await prisma.verificationToken.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ identifier: u.id },
|
||||
{
|
||||
identifier: {
|
||||
startsWith:
|
||||
"password-reset:" +
|
||||
Buffer.from(u.id).toString("base64url") +
|
||||
":",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.kita.deleteMany({ where: { id: { in: tenants } } });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
test("Reset speichert nur Hash, gültige App-Adresse und 30-Minuten-Frist; GET verbraucht den Link nicht", async () => {
|
||||
const before = Date.now();
|
||||
await service.requestPasswordReset(f.user.email);
|
||||
const link = token(),
|
||||
rows = await prisma.verificationToken.findMany({
|
||||
where: { token: tokenHash(link) },
|
||||
});
|
||||
assert.equal(rows.length, 1);
|
||||
assert.notEqual(rows[0].token, link);
|
||||
assert.ok(rows[0].identifier.startsWith("password-reset:"));
|
||||
assert.ok(rows[0].expires.getTime() >= before + 29 * 60_000);
|
||||
assert.ok(rows[0].expires.getTime() <= Date.now() + 30 * 60_000);
|
||||
assert.equal(await service.isPasswordResetValid(link), true);
|
||||
assert.equal(await service.isPasswordResetValid(link), true);
|
||||
assert.match(
|
||||
emails[0].react.props.resetLink,
|
||||
/^http:\/\/127\.0\.0\.1:3014\/neues-passwort\//,
|
||||
);
|
||||
const html = await render(emails[0].react);
|
||||
assert.match(html, /30 Minuten/);
|
||||
assert.match(html, /ignoriere|ignorieren/);
|
||||
});
|
||||
test("Anfrage antwortet gleich für bekannte, unbekannte und deaktivierte Adresse; Verarbeitung folgt nach der Antwort", async () => {
|
||||
const known = await actions.requestResetAction(
|
||||
{},
|
||||
form({ email: f.user.email }),
|
||||
);
|
||||
assert.equal(emails.length, 0);
|
||||
assert.equal(background.length, 1);
|
||||
await drain();
|
||||
assert.equal(emails.length, 1);
|
||||
const missing = await actions.requestResetAction(
|
||||
{},
|
||||
form({ email: randomUUID() + "@example.invalid" }),
|
||||
);
|
||||
await drain();
|
||||
await prisma.user.update({
|
||||
where: { id: f.user.id },
|
||||
data: { disabledAt: new Date() },
|
||||
});
|
||||
const disabled = await actions.requestResetAction(
|
||||
{},
|
||||
form({ email: f.user.email }),
|
||||
);
|
||||
await drain();
|
||||
assert.deepEqual(known, missing);
|
||||
assert.deepEqual(known, disabled);
|
||||
assert.equal(emails.length, 1);
|
||||
});
|
||||
test("Anfrage normalisiert E-Mail und lässt offene Einladungen unverändert", async () => {
|
||||
await service.requestPasswordReset(" " + f.user.email.toUpperCase() + " ");
|
||||
assert.equal(emails.length, 1);
|
||||
await prisma.user.update({
|
||||
where: { id: f.user.id },
|
||||
data: { emailVerifiedAt: null, passwordHash: "" },
|
||||
});
|
||||
emails.length = 0;
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: f.user.id,
|
||||
token: "invitation-" + randomUUID(),
|
||||
expires: new Date(Date.now() + 60_000),
|
||||
},
|
||||
});
|
||||
await service.requestPasswordReset(f.user.email);
|
||||
assert.equal(emails.length, 0);
|
||||
assert.equal(
|
||||
await prisma.verificationToken.count({ where: { identifier: f.user.id } }),
|
||||
1,
|
||||
);
|
||||
});
|
||||
test("Neue Anfrage ersetzt nur frühere Reset-Links und nicht Aktivierungstokens", async () => {
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: f.user.id,
|
||||
token: "invite-" + randomUUID(),
|
||||
expires: new Date(Date.now() + 60_000),
|
||||
},
|
||||
});
|
||||
await service.requestPasswordReset(f.user.email);
|
||||
const first = token();
|
||||
await service.requestPasswordReset(f.user.email);
|
||||
assert.equal(await service.isPasswordResetValid(first), false);
|
||||
assert.equal(await service.isPasswordResetValid(token()), true);
|
||||
assert.equal(
|
||||
await prisma.verificationToken.count({ where: { identifier: f.user.id } }),
|
||||
1,
|
||||
);
|
||||
});
|
||||
test("Passwort wird gehasht, Lockout aufgehoben und alle alten Anmeldungen werden ungültig", async () => {
|
||||
await service.requestPasswordReset(f.user.email);
|
||||
await prisma.user.update({
|
||||
where: { id: f.user.id },
|
||||
data: {
|
||||
lockedUntil: new Date(Date.now() + 60_000),
|
||||
failedLoginAttempts: 10,
|
||||
},
|
||||
});
|
||||
await service.resetPassword(resetInput());
|
||||
const fresh = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: f.user.id },
|
||||
});
|
||||
assert.equal(await compare(newPassword, fresh.passwordHash), true);
|
||||
assert.equal(await compare(oldPassword, fresh.passwordHash), false);
|
||||
assert.equal(fresh.sessionVersion, 1);
|
||||
assert.equal(fresh.failedLoginAttempts, 0);
|
||||
assert.equal(fresh.lockedUntil, null);
|
||||
assert.equal(
|
||||
await jwt({
|
||||
token: { id: f.user.id, sessionVersion: 0, authenticatedAt: Date.now() },
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
test("Reset-Link ist nur einmal nutzbar", async () => {
|
||||
await service.requestPasswordReset(f.user.email);
|
||||
const payload = resetInput();
|
||||
await service.resetPassword(payload);
|
||||
assert.equal(await service.isPasswordResetValid(payload.token), false);
|
||||
await assert.rejects(service.resetPassword(payload), /Link ist ungültig/);
|
||||
});
|
||||
test("Parallele Verwendung desselben Links ändert das Passwort genau einmal", async () => {
|
||||
await service.requestPasswordReset(f.user.email);
|
||||
const payload = resetInput();
|
||||
const results = await Promise.allSettled([
|
||||
service.resetPassword(payload),
|
||||
service.resetPassword({
|
||||
...payload,
|
||||
password: "Another-test-password",
|
||||
confirmation: "Another-test-password",
|
||||
}),
|
||||
]);
|
||||
assert.equal(results.filter((r) => r.status === "fulfilled").length, 1);
|
||||
assert.equal(
|
||||
(await prisma.user.findUniqueOrThrow({ where: { id: f.user.id } }))
|
||||
.sessionVersion,
|
||||
1,
|
||||
);
|
||||
});
|
||||
test("Abgelaufene, erfundene und zweckfremde Aktivierungslinks funktionieren nicht", async () => {
|
||||
await service.requestPasswordReset(f.user.email);
|
||||
const link = token();
|
||||
await prisma.verificationToken.update({
|
||||
where: { token: tokenHash(link) },
|
||||
data: { expires: new Date(0) },
|
||||
});
|
||||
await assert.rejects(
|
||||
service.resetPassword(resetInput(link)),
|
||||
/Link ist ungültig/,
|
||||
);
|
||||
const fake = "a".repeat(64);
|
||||
await assert.rejects(
|
||||
service.resetPassword(resetInput(fake)),
|
||||
/Link ist ungültig/,
|
||||
);
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: f.user.id,
|
||||
token: tokenHash(fake),
|
||||
expires: new Date(Date.now() + 60_000),
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
service.resetPassword(resetInput(fake)),
|
||||
/Link ist ungültig/,
|
||||
);
|
||||
});
|
||||
test("Deaktivierung und Kontolöschung verhindern die Verwendung eines vorhandenen Links", async () => {
|
||||
await service.requestPasswordReset(f.user.email);
|
||||
const link = token();
|
||||
await prisma.user.update({
|
||||
where: { id: f.user.id },
|
||||
data: { disabledAt: new Date(), sessionVersion: { increment: 1 } },
|
||||
});
|
||||
await assert.rejects(
|
||||
service.resetPassword(resetInput(link)),
|
||||
/Link ist ungültig/,
|
||||
);
|
||||
await prisma.user.delete({ where: { id: f.user.id } });
|
||||
assert.equal(await service.isPasswordResetValid(link), false);
|
||||
});
|
||||
test("Ein Reset-Link für die alte E-Mail-Adresse bleibt auch nach neuer Aktivierung ungültig", async () => {
|
||||
await service.requestPasswordReset(f.user.email);
|
||||
const link = token();
|
||||
await prisma.user.update({
|
||||
where: { id: f.user.id },
|
||||
data: {
|
||||
email: randomUUID() + "@example.invalid",
|
||||
sessionVersion: { increment: 1 },
|
||||
emailVerifiedAt: null,
|
||||
passwordHash: "",
|
||||
},
|
||||
});
|
||||
await prisma.user.update({
|
||||
where: { id: f.user.id },
|
||||
data: { emailVerifiedAt: new Date(), passwordHash: initialHash },
|
||||
});
|
||||
assert.equal(await service.isPasswordResetValid(link), false);
|
||||
await assert.rejects(
|
||||
service.resetPassword(resetInput(link)),
|
||||
/Link ist ungültig/,
|
||||
);
|
||||
});
|
||||
test("Mailfehler hinterlässt keinen nutzbaren Link und verrät keine Kontodaten in der öffentlichen Antwort", async () => {
|
||||
transportFailure = true;
|
||||
const response = await actions.requestResetAction(
|
||||
{},
|
||||
form({ email: f.user.email }),
|
||||
);
|
||||
await drain();
|
||||
assert.equal(response.message, service.passwordResetMessage);
|
||||
assert.equal(await service.isPasswordResetValid(token()), false);
|
||||
assert.equal(
|
||||
(await prisma.user.findUniqueOrThrow({ where: { id: f.user.id } }))
|
||||
.passwordHash,
|
||||
initialHash,
|
||||
);
|
||||
});
|
||||
test("Anfragelimit greift ohne abweichende öffentliche Antwort", async () => {
|
||||
const responses = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
responses.push(
|
||||
await actions.requestResetAction({}, form({ email: f.user.email })),
|
||||
);
|
||||
await drain();
|
||||
}
|
||||
assert.equal(emails.length, 3);
|
||||
for (const response of responses) assert.deepEqual(response, responses[0]);
|
||||
});
|
||||
test("Passwortbestätigung und UTF-8-Grenze werden geprüft, bevor ein gültiger Link verbraucht wird", async () => {
|
||||
await service.requestPasswordReset(f.user.email);
|
||||
const link = token();
|
||||
await assert.rejects(
|
||||
service.resetPassword({ ...resetInput(link), confirmation: "different" }),
|
||||
/stimmen nicht überein/,
|
||||
);
|
||||
await assert.rejects(
|
||||
service.resetPassword(resetInput(link, "😀".repeat(19))),
|
||||
/72 UTF-8/,
|
||||
);
|
||||
assert.equal(await service.isPasswordResetValid(link), true);
|
||||
});
|
||||
test("Eigene Passwortänderung verlangt aktuelles Passwort und kann kein fremdes Konto verändern", async () => {
|
||||
const other = await prisma.user.create({
|
||||
data: {
|
||||
kitaId: f.kita.id,
|
||||
email: randomUUID() + "@example.invalid",
|
||||
firstName: "Andere",
|
||||
lastName: "Person",
|
||||
passwordHash: initialHash,
|
||||
emailVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
service.changeOwnPassword(current, {
|
||||
currentPassword: "wrong",
|
||||
password: newPassword,
|
||||
confirmation: newPassword,
|
||||
}),
|
||||
/aktuelle Passwort/,
|
||||
);
|
||||
assert.equal(
|
||||
(await prisma.user.findUnique({ where: { id: f.user.id } })).sessionVersion,
|
||||
0,
|
||||
);
|
||||
await service.changeOwnPassword(current, {
|
||||
userId: other.id,
|
||||
currentPassword: oldPassword,
|
||||
password: newPassword,
|
||||
confirmation: newPassword,
|
||||
});
|
||||
assert.equal(
|
||||
(await prisma.user.findUnique({ where: { id: other.id } })).passwordHash,
|
||||
initialHash,
|
||||
);
|
||||
assert.equal(
|
||||
await compare(
|
||||
newPassword,
|
||||
(await prisma.user.findUnique({ where: { id: f.user.id } })).passwordHash,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
test("Aktuelles Passwort wiederverwenden und veraltete Sitzung werden zurückgewiesen", async () => {
|
||||
await assert.rejects(
|
||||
service.changeOwnPassword(current, {
|
||||
currentPassword: oldPassword,
|
||||
password: oldPassword,
|
||||
confirmation: oldPassword,
|
||||
}),
|
||||
/anderes neues Passwort/,
|
||||
);
|
||||
await assert.rejects(
|
||||
service.changeOwnPassword(
|
||||
{ ...current, sessionVersion: 4 },
|
||||
{
|
||||
currentPassword: oldPassword,
|
||||
password: newPassword,
|
||||
confirmation: newPassword,
|
||||
},
|
||||
),
|
||||
/aktuelle Passwort/,
|
||||
);
|
||||
});
|
||||
test("Profil-Aktion ändert Passwort und widerruft ausstehende Reset-Links", async () => {
|
||||
await service.requestPasswordReset(f.user.email);
|
||||
const link = token();
|
||||
const result = await actions.changePasswordAction(
|
||||
{},
|
||||
form({
|
||||
currentPassword: oldPassword,
|
||||
password: newPassword,
|
||||
confirmation: newPassword,
|
||||
}),
|
||||
);
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(await service.isPasswordResetValid(link), false);
|
||||
assert.equal(
|
||||
await jwt({
|
||||
token: { id: f.user.id, sessionVersion: 0, authenticatedAt: Date.now() },
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
test("Anonyme Reset-Aktion benötigt keinen Login, validiert das Token und gibt keine internen Daten zurück", async () => {
|
||||
await service.requestPasswordReset(f.user.email);
|
||||
assert.equal(
|
||||
(await actions.finishResetAction({}, form(resetInput()))).success,
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(
|
||||
Object.keys(
|
||||
await actions.finishResetAction(
|
||||
{},
|
||||
form({ ...resetInput(), token: "invalid" }),
|
||||
),
|
||||
),
|
||||
["error"],
|
||||
);
|
||||
});
|
||||
|
||||
test("Ein fehlgeschlagener älterer Versand entfernt keinen inzwischen neu angeforderten Link", async () => {
|
||||
let oldToken;
|
||||
await service.requestPasswordReset(f.user.email, async (input) => {
|
||||
oldToken = new URL(input.react.props.resetLink).pathname.split("/").pop();
|
||||
await service.requestPasswordReset(f.user.email);
|
||||
return { success: false, error: "test-only" };
|
||||
});
|
||||
assert.equal(await service.isPasswordResetValid(oldToken), false);
|
||||
assert.equal(await service.isPasswordResetValid(token()), true);
|
||||
});
|
||||
|
||||
test("Wiederholte falsche aktuelle Passwörter werden im Profil begrenzt", async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
assert.match(
|
||||
(
|
||||
await actions.changePasswordAction(
|
||||
{},
|
||||
form({
|
||||
currentPassword: "wrong",
|
||||
password: newPassword,
|
||||
confirmation: newPassword,
|
||||
}),
|
||||
)
|
||||
).error,
|
||||
/aktuelle Passwort/,
|
||||
);
|
||||
}
|
||||
assert.match(
|
||||
(
|
||||
await actions.changePasswordAction(
|
||||
{},
|
||||
form({
|
||||
currentPassword: oldPassword,
|
||||
password: newPassword,
|
||||
confirmation: newPassword,
|
||||
}),
|
||||
)
|
||||
).error,
|
||||
/Zu viele Versuche/,
|
||||
);
|
||||
assert.equal(
|
||||
(await prisma.user.findUniqueOrThrow({ where: { id: f.user.id } }))
|
||||
.sessionVersion,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("Fehlende oder unzulässige App-Adresse verschickt keinen nutzbaren Reset-Link", async () => {
|
||||
const keys = ["AUTH_URL", "NEXTAUTH_URL", "NEXT_PUBLIC_SITE_URL", "NODE_ENV"];
|
||||
const old = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
|
||||
try {
|
||||
process.env.NODE_ENV = "production";
|
||||
delete process.env.AUTH_URL;
|
||||
delete process.env.NEXTAUTH_URL;
|
||||
delete process.env.NEXT_PUBLIC_SITE_URL;
|
||||
await service.requestPasswordReset(f.user.email);
|
||||
process.env.NEXT_PUBLIC_SITE_URL = "javascript:invalid";
|
||||
await service.requestPasswordReset(f.user.email);
|
||||
assert.equal(emails.length, 0);
|
||||
assert.equal(
|
||||
await prisma.verificationToken.count({
|
||||
where: {
|
||||
identifier: {
|
||||
startsWith:
|
||||
"password-reset:" +
|
||||
Buffer.from(f.user.id).toString("base64url") +
|
||||
":",
|
||||
},
|
||||
},
|
||||
}),
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
for (const key of keys) {
|
||||
if (old[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = old[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user