feat(bereiche): Bereiche verwalten mit Rollenprüfung und Audit
Bereiche mit Farbe, Arbeitsfenster und Tageskapazität – die erste schreibende Funktion und damit der Beleg, dass die Kette trägt: zod prüft jede Eingabe, die Rolle wird in der Seite und noch einmal in der Server Action geprüft, jede Änderung hinterlässt einen Audit-Eintrag. Die Audit-Details werden vor dem Speichern gesäubert: Felder, deren Name nach Passwort, Token, Betreff oder Mailinhalt aussieht, werden ersetzt. Das gilt auch in verschachtelten Objekten und Listen. Dazu die Protokollseite für die Verwaltung und die Startseite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cc013fd6a1
commit
e9a2b4b463
@@ -0,0 +1,235 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react'
|
||||||
|
import { Loader2, Trash2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { KAPAZITAET_FELDER, WOCHENTAGE } from '@/lib/bereich'
|
||||||
|
import {
|
||||||
|
bereichAendern,
|
||||||
|
bereichAnlegen,
|
||||||
|
bereichLoeschen,
|
||||||
|
type Rueckmeldung,
|
||||||
|
} from '@/server/aktionen/bereiche'
|
||||||
|
|
||||||
|
type Werte = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
farbe: string
|
||||||
|
reihenfolge: number
|
||||||
|
aktiv: boolean
|
||||||
|
arbeitsfensterVon: string
|
||||||
|
arbeitsfensterBis: string
|
||||||
|
} & Record<(typeof KAPAZITAET_FELDER)[number], number>
|
||||||
|
|
||||||
|
const LEER = {
|
||||||
|
name: '',
|
||||||
|
farbe: '#2563eb',
|
||||||
|
reihenfolge: 0,
|
||||||
|
aktiv: true,
|
||||||
|
arbeitsfensterVon: '08:00',
|
||||||
|
arbeitsfensterBis: '17:00',
|
||||||
|
kapazitaetMo: 0,
|
||||||
|
kapazitaetDi: 0,
|
||||||
|
kapazitaetMi: 0,
|
||||||
|
kapazitaetDo: 0,
|
||||||
|
kapazitaetFr: 0,
|
||||||
|
kapazitaetSa: 0,
|
||||||
|
kapazitaetSo: 0,
|
||||||
|
} satisfies Omit<Werte, 'id'>
|
||||||
|
|
||||||
|
export function BereichFormular({ bereich }: { bereich?: Werte }) {
|
||||||
|
const [laeuft, starte] = useTransition()
|
||||||
|
const [felder, setFelder] = useState<Record<string, string>>({})
|
||||||
|
const [offen, setOffen] = useState(!bereich)
|
||||||
|
const werte = bereich ?? LEER
|
||||||
|
|
||||||
|
function verarbeite(antwort: Rueckmeldung, formular: HTMLFormElement) {
|
||||||
|
if (antwort.ok) {
|
||||||
|
setFelder({})
|
||||||
|
toast.success(antwort.meldung)
|
||||||
|
if (!bereich) formular.reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFelder(antwort.felder ?? {})
|
||||||
|
toast.error(antwort.fehler)
|
||||||
|
}
|
||||||
|
|
||||||
|
function absenden(ereignis: React.FormEvent<HTMLFormElement>) {
|
||||||
|
ereignis.preventDefault()
|
||||||
|
const formular = ereignis.currentTarget
|
||||||
|
const daten = new FormData(formular)
|
||||||
|
starte(async () => {
|
||||||
|
const antwort = bereich
|
||||||
|
? await bereichAendern(bereich.id, daten)
|
||||||
|
: await bereichAnlegen(daten)
|
||||||
|
verarbeite(antwort, formular)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function loeschen() {
|
||||||
|
if (!bereich) return
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('id', bereich.id)
|
||||||
|
starte(async () => {
|
||||||
|
const antwort = await bereichLoeschen(daten)
|
||||||
|
if (antwort.ok) toast.success(antwort.meldung)
|
||||||
|
else toast.error(antwort.fehler)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bereich && !offen) {
|
||||||
|
return (
|
||||||
|
<Button art="umriss" groesse="klein" onClick={() => setOffen(true)}>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={absenden} className="flex flex-col gap-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor={`name-${werte.name}`}>Name</Label>
|
||||||
|
<Input
|
||||||
|
id={`name-${werte.name}`}
|
||||||
|
name="name"
|
||||||
|
defaultValue={werte.name}
|
||||||
|
required
|
||||||
|
aria-invalid={Boolean(felder.name)}
|
||||||
|
aria-describedby={felder.name ? `name-fehler-${werte.name}` : undefined}
|
||||||
|
/>
|
||||||
|
{felder.name && (
|
||||||
|
<p id={`name-fehler-${werte.name}`} className="text-gefahr text-xs">
|
||||||
|
{felder.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor={`farbe-${werte.name}`}>Farbe</Label>
|
||||||
|
<Input
|
||||||
|
id={`farbe-${werte.name}`}
|
||||||
|
name="farbe"
|
||||||
|
type="color"
|
||||||
|
defaultValue={werte.farbe}
|
||||||
|
className="h-9 w-20 p-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor={`von-${werte.name}`}>Arbeitsfenster von</Label>
|
||||||
|
<Input
|
||||||
|
id={`von-${werte.name}`}
|
||||||
|
name="arbeitsfensterVon"
|
||||||
|
type="time"
|
||||||
|
defaultValue={werte.arbeitsfensterVon}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor={`bis-${werte.name}`}>bis</Label>
|
||||||
|
<Input
|
||||||
|
id={`bis-${werte.name}`}
|
||||||
|
name="arbeitsfensterBis"
|
||||||
|
type="time"
|
||||||
|
defaultValue={werte.arbeitsfensterBis}
|
||||||
|
required
|
||||||
|
aria-invalid={Boolean(felder.arbeitsfensterBis)}
|
||||||
|
/>
|
||||||
|
{felder.arbeitsfensterBis && (
|
||||||
|
<p className="text-gefahr text-xs">{felder.arbeitsfensterBis}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset className="flex flex-col gap-2">
|
||||||
|
<legend className="mb-2 text-sm font-medium">Tageskapazität in Minuten</legend>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-7">
|
||||||
|
{KAPAZITAET_FELDER.map((feld, i) => (
|
||||||
|
<div key={feld} className="flex flex-col gap-1">
|
||||||
|
<Label htmlFor={`${feld}-${werte.name}`} className="text-xs font-normal">
|
||||||
|
{WOCHENTAGE[i]}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`${feld}-${werte.name}`}
|
||||||
|
name={feld}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1440}
|
||||||
|
step={15}
|
||||||
|
defaultValue={werte[feld]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{felder.kapazitaetMo && (
|
||||||
|
<p className="text-gefahr text-xs">{felder.kapazitaetMo}</p>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id={`aktiv-${werte.name}`}
|
||||||
|
name="aktiv"
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked={werte.aktiv}
|
||||||
|
className="accent-betont size-4"
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`aktiv-${werte.name}`} className="font-normal">
|
||||||
|
In Benutzung
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor={`reihenfolge-${werte.name}`} className="font-normal">
|
||||||
|
Reihenfolge
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`reihenfolge-${werte.name}`}
|
||||||
|
name="reihenfolge"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={999}
|
||||||
|
defaultValue={werte.reihenfolge}
|
||||||
|
className="w-20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="submit" disabled={laeuft}>
|
||||||
|
{laeuft && <Loader2 className="animate-spin" aria-hidden />}
|
||||||
|
{bereich ? 'Speichern' : 'Bereich anlegen'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{bereich && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
art="leise"
|
||||||
|
onClick={() => setOffen(false)}
|
||||||
|
disabled={laeuft}
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
art="gefahr"
|
||||||
|
onClick={loeschen}
|
||||||
|
disabled={laeuft}
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden />
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { BereichFormular } from '@/app/(app)/bereiche/bereich-formular'
|
||||||
|
import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card'
|
||||||
|
import { KAPAZITAET_FELDER, WOCHENTAGE, wochenKapazitaet } from '@/lib/bereich'
|
||||||
|
import { istAdmin } from '@/lib/rollen'
|
||||||
|
import { alsDauer, alsUhrzeit } from '@/lib/zeit'
|
||||||
|
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Bereiche' }
|
||||||
|
|
||||||
|
export default async function BereicheSeite() {
|
||||||
|
const benutzer = await verlangeAnmeldung('/bereiche')
|
||||||
|
const darfAendern = istAdmin(benutzer.rolle)
|
||||||
|
|
||||||
|
const bereiche = await db.bereich.findMany({
|
||||||
|
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Bereiche</h1>
|
||||||
|
<p className="text-gedaempft-vordergrund mt-1 text-sm">
|
||||||
|
Farbe, Arbeitsfenster und Tageskapazität je Lebensbereich. Die Kapazität
|
||||||
|
begrenzt später, wie viel an einem Tag eingeplant werden kann.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{bereiche.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardInhalt className="text-gedaempft-vordergrund py-10 text-center text-sm">
|
||||||
|
Noch keine Bereiche angelegt.
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-4">
|
||||||
|
{bereiche.map((bereich) => (
|
||||||
|
<li key={bereich.id}>
|
||||||
|
<Card>
|
||||||
|
<CardKopf className="flex-row items-center gap-3">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="size-4 shrink-0 rounded-full ring-1 ring-black/10"
|
||||||
|
style={{ backgroundColor: bereich.farbe }}
|
||||||
|
/>
|
||||||
|
<CardTitel className="flex-1">{bereich.name}</CardTitel>
|
||||||
|
{!bereich.aktiv && (
|
||||||
|
<span className="bg-gedaempft text-gedaempft-vordergrund rounded-md px-2 py-0.5 text-xs">
|
||||||
|
stillgelegt
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardKopf>
|
||||||
|
|
||||||
|
<CardInhalt className="flex flex-col gap-4">
|
||||||
|
<dl className="text-sm">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<dt className="text-gedaempft-vordergrund">Arbeitsfenster:</dt>
|
||||||
|
<dd>
|
||||||
|
{alsUhrzeit(bereich.arbeitsfensterVon)} bis{' '}
|
||||||
|
{alsUhrzeit(bereich.arbeitsfensterBis)} Uhr
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<dt className="text-gedaempft-vordergrund">Woche gesamt:</dt>
|
||||||
|
<dd>{alsDauer(wochenKapazitaet(bereich))}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<ul className="flex flex-wrap gap-2">
|
||||||
|
{KAPAZITAET_FELDER.map((feld, i) => (
|
||||||
|
<li
|
||||||
|
key={feld}
|
||||||
|
className="bg-gedaempft rounded-md px-2 py-1 text-xs"
|
||||||
|
title={WOCHENTAGE[i]}
|
||||||
|
>
|
||||||
|
<span className="text-gedaempft-vordergrund">
|
||||||
|
{WOCHENTAGE[i]?.slice(0, 2)}
|
||||||
|
</span>{' '}
|
||||||
|
{alsDauer(bereich[feld])}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{darfAendern && (
|
||||||
|
<BereichFormular
|
||||||
|
bereich={{
|
||||||
|
id: bereich.id,
|
||||||
|
name: bereich.name,
|
||||||
|
farbe: bereich.farbe,
|
||||||
|
reihenfolge: bereich.reihenfolge,
|
||||||
|
aktiv: bereich.aktiv,
|
||||||
|
arbeitsfensterVon: alsUhrzeit(bereich.arbeitsfensterVon),
|
||||||
|
arbeitsfensterBis: alsUhrzeit(bereich.arbeitsfensterBis),
|
||||||
|
kapazitaetMo: bereich.kapazitaetMo,
|
||||||
|
kapazitaetDi: bereich.kapazitaetDi,
|
||||||
|
kapazitaetMi: bereich.kapazitaetMi,
|
||||||
|
kapazitaetDo: bereich.kapazitaetDo,
|
||||||
|
kapazitaetFr: bereich.kapazitaetFr,
|
||||||
|
kapazitaetSa: bereich.kapazitaetSa,
|
||||||
|
kapazitaetSo: bereich.kapazitaetSo,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{darfAendern && (
|
||||||
|
<Card>
|
||||||
|
<CardKopf>
|
||||||
|
<CardTitel>Neuen Bereich anlegen</CardTitel>
|
||||||
|
</CardKopf>
|
||||||
|
<CardInhalt>
|
||||||
|
<BereichFormular />
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { PasskeyVerwaltung } from '@/app/(app)/einstellungen/passkey-verwaltung'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardBeschreibung,
|
||||||
|
CardInhalt,
|
||||||
|
CardKopf,
|
||||||
|
CardTitel,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { ROLLEN_NAME } from '@/lib/rollen'
|
||||||
|
import { alsDatumUhrzeit } from '@/lib/zeit'
|
||||||
|
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Einstellungen' }
|
||||||
|
|
||||||
|
export default async function EinstellungenSeite() {
|
||||||
|
const benutzer = await verlangeAnmeldung('/einstellungen')
|
||||||
|
|
||||||
|
const passkeys = await db.passkey.findMany({
|
||||||
|
where: { benutzerId: benutzer.id },
|
||||||
|
orderBy: { erstelltAm: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
bezeichnung: true,
|
||||||
|
erstelltAm: true,
|
||||||
|
zuletztBenutzt: true,
|
||||||
|
gesichert: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Einstellungen</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardKopf>
|
||||||
|
<CardTitel className="text-base">Konto</CardTitel>
|
||||||
|
</CardKopf>
|
||||||
|
<CardInhalt>
|
||||||
|
<dl className="grid gap-2 text-sm sm:grid-cols-[10rem_1fr]">
|
||||||
|
<dt className="text-gedaempft-vordergrund">Name</dt>
|
||||||
|
<dd>{benutzer.name ?? '—'}</dd>
|
||||||
|
<dt className="text-gedaempft-vordergrund">E-Mail</dt>
|
||||||
|
<dd>{benutzer.email}</dd>
|
||||||
|
<dt className="text-gedaempft-vordergrund">Rolle</dt>
|
||||||
|
<dd>{ROLLEN_NAME[benutzer.rolle]}</dd>
|
||||||
|
</dl>
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardKopf>
|
||||||
|
<CardTitel className="text-base">Passkeys</CardTitel>
|
||||||
|
<CardBeschreibung>
|
||||||
|
Mit einem Passkey meldest du dich per Fingerabdruck, Gesicht oder Geräte-PIN
|
||||||
|
an – ohne Passwort. Hinterlege am besten zwei, damit du bei Verlust eines
|
||||||
|
Geräts nicht ausgesperrt bist.
|
||||||
|
</CardBeschreibung>
|
||||||
|
</CardKopf>
|
||||||
|
<CardInhalt>
|
||||||
|
<PasskeyVerwaltung
|
||||||
|
passkeys={passkeys.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
bezeichnung: p.bezeichnung,
|
||||||
|
erstelltAm: alsDatumUhrzeit(p.erstelltAm),
|
||||||
|
zuletztBenutzt: p.zuletztBenutzt ? alsDatumUhrzeit(p.zuletztBenutzt) : null,
|
||||||
|
gesichert: p.gesichert,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { startRegistration } from '@simplewebauthn/browser'
|
||||||
|
import { Fingerprint, Loader2, Trash2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { passkeyEntfernen } from '@/server/aktionen/passkeys'
|
||||||
|
|
||||||
|
export type PasskeyAnzeige = {
|
||||||
|
id: string
|
||||||
|
bezeichnung: string
|
||||||
|
erstelltAm: string
|
||||||
|
zuletztBenutzt: string | null
|
||||||
|
gesichert: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasskeyVerwaltung({ passkeys }: { passkeys: PasskeyAnzeige[] }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [bezeichnung, setBezeichnung] = useState('')
|
||||||
|
const [legtAn, setLegtAn] = useState(false)
|
||||||
|
const [laeuft, starte] = useTransition()
|
||||||
|
|
||||||
|
async function anlegen(ereignis: React.FormEvent) {
|
||||||
|
ereignis.preventDefault()
|
||||||
|
const name = bezeichnung.trim()
|
||||||
|
if (!name) {
|
||||||
|
toast.error('Bitte einen Namen für das Gerät angeben.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLegtAn(true)
|
||||||
|
try {
|
||||||
|
const antwortOptionen = await fetch('/api/passkey/registrierung')
|
||||||
|
if (!antwortOptionen.ok) {
|
||||||
|
toast.error('Die Anfrage wurde abgelehnt.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionen = (await antwortOptionen.json()) as Parameters<
|
||||||
|
typeof startRegistration
|
||||||
|
>[0]['optionsJSON']
|
||||||
|
|
||||||
|
const antwort = await startRegistration({ optionsJSON: optionen })
|
||||||
|
|
||||||
|
const gespeichert = await fetch('/api/passkey/registrierung', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ bezeichnung: name, antwort }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const ergebnis = (await gespeichert.json()) as { fehler?: string }
|
||||||
|
if (!gespeichert.ok) {
|
||||||
|
toast.error(ergebnis.fehler ?? 'Der Passkey konnte nicht gespeichert werden.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`Passkey „${name}" angelegt.`)
|
||||||
|
setBezeichnung('')
|
||||||
|
router.refresh()
|
||||||
|
} catch (fehler) {
|
||||||
|
// Bricht der Benutzer den Systemdialog ab, ist das kein Fehler.
|
||||||
|
if (fehler instanceof Error && fehler.name === 'NotAllowedError') return
|
||||||
|
if (fehler instanceof Error && fehler.name === 'InvalidStateError') {
|
||||||
|
toast.error('Dieses Gerät ist hier schon hinterlegt.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.error('Der Passkey konnte nicht angelegt werden.')
|
||||||
|
} finally {
|
||||||
|
setLegtAn(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function entfernen(id: string) {
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('id', id)
|
||||||
|
starte(async () => {
|
||||||
|
const antwort = await passkeyEntfernen(daten)
|
||||||
|
if (antwort.ok) {
|
||||||
|
toast.success(antwort.meldung)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(antwort.fehler)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{passkeys.length === 0 ? (
|
||||||
|
<p className="text-gedaempft-vordergrund text-sm">
|
||||||
|
Noch kein Passkey hinterlegt.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-rand divide-y">
|
||||||
|
{passkeys.map((passkey) => (
|
||||||
|
<li
|
||||||
|
key={passkey.id}
|
||||||
|
className="flex flex-wrap items-center gap-x-4 gap-y-1 py-3"
|
||||||
|
>
|
||||||
|
<Fingerprint className="text-gedaempft-vordergrund size-4" aria-hidden />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{passkey.bezeichnung}</p>
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
Angelegt {passkey.erstelltAm}
|
||||||
|
{passkey.zuletztBenutzt
|
||||||
|
? ` · zuletzt benutzt ${passkey.zuletztBenutzt}`
|
||||||
|
: ' · noch nicht benutzt'}
|
||||||
|
{passkey.gesichert ? ' · geräteübergreifend' : ' · nur dieses Gerät'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
art="leise"
|
||||||
|
groesse="quadrat"
|
||||||
|
title={`Passkey ${passkey.bezeichnung} entfernen`}
|
||||||
|
onClick={() => entfernen(passkey.id)}
|
||||||
|
disabled={laeuft}
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden />
|
||||||
|
<span className="sr-only">Entfernen</span>
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={(e) => void anlegen(e)} className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label htmlFor="passkey-name">Neuen Passkey anlegen</Label>
|
||||||
|
<Input
|
||||||
|
id="passkey-name"
|
||||||
|
value={bezeichnung}
|
||||||
|
onChange={(e) => setBezeichnung(e.target.value)}
|
||||||
|
placeholder="z. B. Diensthandy"
|
||||||
|
maxLength={60}
|
||||||
|
disabled={legtAn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" art="umriss" disabled={legtAn}>
|
||||||
|
{legtAn ? (
|
||||||
|
<Loader2 className="animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Fingerprint aria-hidden />
|
||||||
|
)}
|
||||||
|
Anlegen
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Kopfleiste } from '@/components/kopfleiste'
|
||||||
|
import { INSTANZ_NAME } from '@/lib/instanz'
|
||||||
|
import { versionAnzeige } from '@/lib/version'
|
||||||
|
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
|
import { aktuelleInstanz } from '@/server/instanz'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alles unterhalb von (app) setzt eine Anmeldung voraus. Der Riegel sitzt
|
||||||
|
* hier im Layout und zusaetzlich in jeder Server Action – nicht nur in der
|
||||||
|
* Oberflaeche.
|
||||||
|
*/
|
||||||
|
export default async function AngemeldetesLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const benutzer = await verlangeAnmeldung()
|
||||||
|
const instanz = aktuelleInstanz()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh flex-col">
|
||||||
|
<Kopfleiste
|
||||||
|
name={benutzer.name ?? benutzer.email}
|
||||||
|
email={benutzer.email}
|
||||||
|
rolle={benutzer.rolle}
|
||||||
|
instanzName={INSTANZ_NAME[instanz]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">{children}</main>
|
||||||
|
|
||||||
|
<footer className="text-gedaempft-vordergrund border-rand border-t px-4 py-4 text-center text-xs">
|
||||||
|
Planer {versionAnzeige()} · {INSTANZ_NAME[instanz]}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardBeschreibung,
|
||||||
|
CardInhalt,
|
||||||
|
CardKopf,
|
||||||
|
CardTitel,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { wochenKapazitaet } from '@/lib/bereich'
|
||||||
|
import { alsDatum, alsDauer } from '@/lib/zeit'
|
||||||
|
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
|
||||||
|
export default async function StartSeite() {
|
||||||
|
const benutzer = await verlangeAnmeldung('/')
|
||||||
|
const jetzt = new Date()
|
||||||
|
|
||||||
|
const bereiche = await db.bereich.findMany({
|
||||||
|
where: { aktiv: true },
|
||||||
|
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const anrede = benutzer.name?.split(' ')[0] ?? benutzer.email
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Guten Tag, {anrede}</h1>
|
||||||
|
<p className="text-gedaempft-vordergrund mt-1 text-sm">{alsDatum(jetzt)}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-lg font-medium">Deine Bereiche</h2>
|
||||||
|
|
||||||
|
{bereiche.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardInhalt className="text-gedaempft-vordergrund py-8 text-center text-sm">
|
||||||
|
Noch keine Bereiche angelegt.{' '}
|
||||||
|
<Link href="/bereiche" className="text-betont underline underline-offset-4">
|
||||||
|
Jetzt einrichten
|
||||||
|
</Link>
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{bereiche.map((bereich) => (
|
||||||
|
<li key={bereich.id}>
|
||||||
|
<Card>
|
||||||
|
<CardKopf className="flex-row items-center gap-3">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="size-3 shrink-0 rounded-full ring-1 ring-black/10"
|
||||||
|
style={{ backgroundColor: bereich.farbe }}
|
||||||
|
/>
|
||||||
|
<CardTitel className="text-base">{bereich.name}</CardTitel>
|
||||||
|
</CardKopf>
|
||||||
|
<CardInhalt>
|
||||||
|
<p className="text-gedaempft-vordergrund text-sm">
|
||||||
|
{alsDauer(wochenKapazitaet(bereich))} in der Woche eingeplant
|
||||||
|
</p>
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardKopf>
|
||||||
|
<CardTitel className="text-base">Was als Nächstes kommt</CardTitel>
|
||||||
|
<CardBeschreibung>
|
||||||
|
Aufgaben und Ideen folgen mit M2, das Lesen der Kalender mit M3, die
|
||||||
|
Wochenplanung per Ziehen und Ablegen mit M4.
|
||||||
|
</CardBeschreibung>
|
||||||
|
</CardKopf>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { Card, CardInhalt } from '@/components/ui/card'
|
||||||
|
import { alsDatumUhrzeit } from '@/lib/zeit'
|
||||||
|
import { verlangeRolle } from '@/server/berechtigung'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Protokoll' }
|
||||||
|
|
||||||
|
const SEITENGROESSE = 100
|
||||||
|
|
||||||
|
export default async function ProtokollSeite() {
|
||||||
|
// Zweiter Riegel: das Layout prueft die Anmeldung, hier zusaetzlich die Rolle.
|
||||||
|
await verlangeRolle('ADMIN', '/verwaltung/protokoll')
|
||||||
|
|
||||||
|
const eintraege = await db.auditEintrag.findMany({
|
||||||
|
orderBy: { zeitpunkt: 'desc' },
|
||||||
|
take: SEITENGROESSE,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
zeitpunkt: true,
|
||||||
|
benutzerEmail: true,
|
||||||
|
aktion: true,
|
||||||
|
objektTyp: true,
|
||||||
|
objektId: true,
|
||||||
|
details: true,
|
||||||
|
instanz: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Protokoll</h1>
|
||||||
|
<p className="text-gedaempft-vordergrund mt-1 text-sm">
|
||||||
|
Die letzten {SEITENGROESSE} schreibenden Aktionen. Einträge lassen sich nicht
|
||||||
|
ändern und nicht löschen.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{eintraege.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardInhalt className="text-gedaempft-vordergrund py-10 text-center text-sm">
|
||||||
|
Noch nichts protokolliert.
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardInhalt className="overflow-x-auto p-0">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="text-gedaempft-vordergrund border-rand border-b">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-4 py-3 font-medium">
|
||||||
|
Zeitpunkt
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-4 py-3 font-medium">
|
||||||
|
Wer
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-4 py-3 font-medium">
|
||||||
|
Was
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-4 py-3 font-medium">
|
||||||
|
Objekt
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-rand divide-y">
|
||||||
|
{eintraege.map((eintrag) => (
|
||||||
|
<tr key={eintrag.id}>
|
||||||
|
<td className="text-gedaempft-vordergrund px-4 py-2 whitespace-nowrap">
|
||||||
|
{alsDatumUhrzeit(eintrag.zeitpunkt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">{eintrag.benutzerEmail}</td>
|
||||||
|
<td className="px-4 py-2 font-medium">{eintrag.aktion}</td>
|
||||||
|
<td className="text-gedaempft-vordergrund px-4 py-2">
|
||||||
|
{eintrag.objektTyp}
|
||||||
|
{eintrag.details ? (
|
||||||
|
<span className="ml-2 text-xs">
|
||||||
|
{JSON.stringify(eintrag.details)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { ENTFERNT, auditDetails, detailsSaeubern } from './audit'
|
||||||
|
|
||||||
|
describe('auditDetails', () => {
|
||||||
|
it('laesst fachliche Felder unangetastet', () => {
|
||||||
|
expect(auditDetails({ name: 'Gewerbe', farbe: '#2563eb', reihenfolge: 2 })).toEqual({
|
||||||
|
name: 'Gewerbe',
|
||||||
|
farbe: '#2563eb',
|
||||||
|
reihenfolge: 2,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gibt fuer nichts auch nichts zurueck', () => {
|
||||||
|
expect(auditDetails(undefined)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('entfernt Passwoerter und Tokens', () => {
|
||||||
|
expect(
|
||||||
|
auditDetails({
|
||||||
|
email: 'a@b.de',
|
||||||
|
passwort: 'geheim123',
|
||||||
|
passwortHash: '$2b$12$...',
|
||||||
|
access_token: 'ey...',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
email: 'a@b.de',
|
||||||
|
passwort: ENTFERNT,
|
||||||
|
passwortHash: ENTFERNT,
|
||||||
|
access_token: ENTFERNT,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('entfernt Termin-Betreffs und Mailinhalte', () => {
|
||||||
|
expect(
|
||||||
|
auditDetails({ betreff: 'Personalgespräch', body: 'Text', dauer: 30 }),
|
||||||
|
).toEqual({ betreff: ENTFERNT, body: ENTFERNT, dauer: 30 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('achtet nicht auf Gross- und Kleinschreibung', () => {
|
||||||
|
expect(auditDetails({ ClientSecret: 'x', REFRESH_TOKEN: 'y' })).toEqual({
|
||||||
|
ClientSecret: ENTFERNT,
|
||||||
|
REFRESH_TOKEN: ENTFERNT,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('detailsSaeubern', () => {
|
||||||
|
it('greift auch in verschachtelten Objekten', () => {
|
||||||
|
expect(detailsSaeubern({ konto: { anbieter: 'GRAPH', id_token: 'ey...' } })).toEqual({
|
||||||
|
konto: { anbieter: 'GRAPH', id_token: ENTFERNT },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('greift in Listen', () => {
|
||||||
|
expect(detailsSaeubern([{ name: 'a', token: 'x' }])).toEqual([
|
||||||
|
{ name: 'a', token: ENTFERNT },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schreibt Datumsangaben als ISO-Zeichenkette', () => {
|
||||||
|
expect(detailsSaeubern({ am: new Date('2026-09-10T12:00:00Z') })).toEqual({
|
||||||
|
am: '2026-09-10T12:00:00.000Z',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schneidet zu tiefe Verschachtelung ab, statt endlos abzusteigen', () => {
|
||||||
|
expect(detailsSaeubern({ a: { b: { c: { d: { e: { f: 1 } } } } } })).toEqual({
|
||||||
|
a: { b: { c: { d: { e: ENTFERNT } } } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('kommt mit einfachen Werten und null zurecht', () => {
|
||||||
|
expect(detailsSaeubern('text')).toBe('text')
|
||||||
|
expect(detailsSaeubern(42)).toBe(42)
|
||||||
|
expect(detailsSaeubern(null)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Aufbereitung der Audit-Details. Reine Logik, damit sie ohne Datenbank
|
||||||
|
* geprueft werden kann.
|
||||||
|
*
|
||||||
|
* In die Details gehoeren nur fachliche Felder – niemals Tokens, Passwoerter,
|
||||||
|
* Mailinhalte oder Termin-Betreffs. Was hier landet, steht spaeter im
|
||||||
|
* Klartext in der Datenbank und in jeder Sicherung.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Feldnamen, die auch versehentlich nie in einem Audit-Eintrag landen duerfen. */
|
||||||
|
const VERBOTEN = [
|
||||||
|
'passwort',
|
||||||
|
'password',
|
||||||
|
'token',
|
||||||
|
'secret',
|
||||||
|
'geheim',
|
||||||
|
'clientsecret',
|
||||||
|
'betreff',
|
||||||
|
'subject',
|
||||||
|
'inhalt',
|
||||||
|
'body',
|
||||||
|
'nachricht',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ENTFERNT = '[entfernt]'
|
||||||
|
|
||||||
|
function istVerdaechtig(schluessel: string): boolean {
|
||||||
|
const klein = schluessel.toLowerCase()
|
||||||
|
return VERBOTEN.some((v) => klein.includes(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zweiter Riegel gegen Unachtsamkeit: Felder mit verdaechtigem Namen werden
|
||||||
|
* ersetzt, statt gespeichert zu werden. Wirkt auch in verschachtelten
|
||||||
|
* Objekten und in Listen.
|
||||||
|
*/
|
||||||
|
export function detailsSaeubern(wert: unknown, tiefe = 0): unknown {
|
||||||
|
// Tief verschachtelte Strukturen gehoeren nicht in ein Audit-Detail; ab
|
||||||
|
// einer gewissen Tiefe wird abgeschnitten statt endlos abgestiegen.
|
||||||
|
if (tiefe > 4) return ENTFERNT
|
||||||
|
|
||||||
|
if (Array.isArray(wert)) {
|
||||||
|
return wert.map((eintrag) => detailsSaeubern(eintrag, tiefe + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wert !== null && typeof wert === 'object') {
|
||||||
|
if (wert instanceof Date) return wert.toISOString()
|
||||||
|
const sauber: Record<string, unknown> = {}
|
||||||
|
for (const [schluessel, inhalt] of Object.entries(wert)) {
|
||||||
|
sauber[schluessel] = istVerdaechtig(schluessel)
|
||||||
|
? ENTFERNT
|
||||||
|
: detailsSaeubern(inhalt, tiefe + 1)
|
||||||
|
}
|
||||||
|
return sauber
|
||||||
|
}
|
||||||
|
|
||||||
|
return wert
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bequemer Einstieg fuer den Normalfall: ein flaches Objekt. */
|
||||||
|
export function auditDetails(
|
||||||
|
details: Record<string, unknown> | undefined,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
if (!details) return undefined
|
||||||
|
return detailsSaeubern(details) as Record<string, unknown>
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
KAPAZITAET_FELDER,
|
||||||
|
bereichSchema,
|
||||||
|
farbeSchema,
|
||||||
|
kapazitaetAmTag,
|
||||||
|
wochenKapazitaet,
|
||||||
|
} from './bereich'
|
||||||
|
|
||||||
|
const gueltig = {
|
||||||
|
name: 'Gewerbe',
|
||||||
|
farbe: '#2563EB',
|
||||||
|
reihenfolge: 1,
|
||||||
|
aktiv: true,
|
||||||
|
arbeitsfensterVon: '08:00',
|
||||||
|
arbeitsfensterBis: '17:00',
|
||||||
|
kapazitaetMo: 240,
|
||||||
|
kapazitaetDi: 240,
|
||||||
|
kapazitaetMi: 240,
|
||||||
|
kapazitaetDo: 240,
|
||||||
|
kapazitaetFr: 120,
|
||||||
|
kapazitaetSa: 0,
|
||||||
|
kapazitaetSo: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('farbeSchema', () => {
|
||||||
|
it('nimmt sechsstellige und dreistellige Hex-Farben', () => {
|
||||||
|
expect(farbeSchema.parse('#2563eb')).toBe('#2563eb')
|
||||||
|
expect(farbeSchema.parse('#abc')).toBe('#abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('macht Grossbuchstaben klein, damit Vergleiche stimmen', () => {
|
||||||
|
expect(farbeSchema.parse('#2563EB')).toBe('#2563eb')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist Farben ohne Raute und mit falscher Laenge ab', () => {
|
||||||
|
expect(farbeSchema.safeParse('2563eb').success).toBe(false)
|
||||||
|
expect(farbeSchema.safeParse('#12345').success).toBe(false)
|
||||||
|
expect(farbeSchema.safeParse('blau').success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('bereichSchema', () => {
|
||||||
|
it('nimmt einen vollstaendigen Bereich an und rechnet Uhrzeiten in Minuten um', () => {
|
||||||
|
const ergebnis = bereichSchema.parse(gueltig)
|
||||||
|
expect(ergebnis.arbeitsfensterVon).toBe(480)
|
||||||
|
expect(ergebnis.arbeitsfensterBis).toBe(1020)
|
||||||
|
expect(ergebnis.farbe).toBe('#2563eb')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verlangt einen Namen mit mindestens zwei Zeichen', () => {
|
||||||
|
const ergebnis = bereichSchema.safeParse({ ...gueltig, name: 'A' })
|
||||||
|
expect(ergebnis.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist ein Arbeitsfenster ab, das vor seinem Beginn endet', () => {
|
||||||
|
const ergebnis = bereichSchema.safeParse({
|
||||||
|
...gueltig,
|
||||||
|
arbeitsfensterVon: '17:00',
|
||||||
|
arbeitsfensterBis: '08:00',
|
||||||
|
})
|
||||||
|
expect(ergebnis.success).toBe(false)
|
||||||
|
if (!ergebnis.success) {
|
||||||
|
expect(ergebnis.error.issues[0]?.message).toMatch(/nach seinem Beginn enden/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist ein Arbeitsfenster ohne Dauer ab', () => {
|
||||||
|
const ergebnis = bereichSchema.safeParse({
|
||||||
|
...gueltig,
|
||||||
|
arbeitsfensterVon: '08:00',
|
||||||
|
arbeitsfensterBis: '08:00',
|
||||||
|
})
|
||||||
|
expect(ergebnis.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist eine Kapazitaet ab, die laenger ist als das Arbeitsfenster', () => {
|
||||||
|
// Fenster 08:00–17:00 sind 540 Minuten.
|
||||||
|
const ergebnis = bereichSchema.safeParse({ ...gueltig, kapazitaetMo: 600 })
|
||||||
|
expect(ergebnis.success).toBe(false)
|
||||||
|
if (!ergebnis.success) {
|
||||||
|
expect(ergebnis.error.issues[0]?.message).toMatch(/Arbeitsfenster/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('laesst eine Kapazitaet genau in Fensterlaenge zu', () => {
|
||||||
|
expect(bereichSchema.safeParse({ ...gueltig, kapazitaetMo: 540 }).success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist negative und uebergrosse Kapazitaeten ab', () => {
|
||||||
|
expect(bereichSchema.safeParse({ ...gueltig, kapazitaetMo: -1 }).success).toBe(false)
|
||||||
|
expect(bereichSchema.safeParse({ ...gueltig, kapazitaetMo: 1441 }).success).toBe(
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist eine unsinnige Uhrzeit ab', () => {
|
||||||
|
expect(
|
||||||
|
bereichSchema.safeParse({ ...gueltig, arbeitsfensterVon: '25:00' }).success,
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('wochenKapazitaet', () => {
|
||||||
|
it('summiert alle sieben Tage', () => {
|
||||||
|
const bereich = bereichSchema.parse(gueltig)
|
||||||
|
expect(wochenKapazitaet(bereich)).toBe(1080)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ist null, wenn nichts eingeplant ist', () => {
|
||||||
|
const leer = Object.fromEntries(KAPAZITAET_FELDER.map((f) => [f, 0])) as Record<
|
||||||
|
(typeof KAPAZITAET_FELDER)[number],
|
||||||
|
number
|
||||||
|
>
|
||||||
|
expect(wochenKapazitaet(leer)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('kapazitaetAmTag', () => {
|
||||||
|
it('trifft den richtigen Wochentag, Montag ist null', () => {
|
||||||
|
const bereich = bereichSchema.parse(gueltig)
|
||||||
|
expect(kapazitaetAmTag(bereich, 0)).toBe(240)
|
||||||
|
expect(kapazitaetAmTag(bereich, 4)).toBe(120)
|
||||||
|
expect(kapazitaetAmTag(bereich, 6)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gibt fuer einen Index ausserhalb der Woche null zurueck', () => {
|
||||||
|
const bereich = bereichSchema.parse(gueltig)
|
||||||
|
expect(kapazitaetAmTag(bereich, 7)).toBe(0)
|
||||||
|
expect(kapazitaetAmTag(bereich, -1)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { ausUhrzeit } from '@/lib/zeit'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regeln fuer Bereiche. Reine Logik, damit Formular und Server Action
|
||||||
|
* dieselbe Antwort geben.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const WOCHENTAGE = [
|
||||||
|
'Montag',
|
||||||
|
'Dienstag',
|
||||||
|
'Mittwoch',
|
||||||
|
'Donnerstag',
|
||||||
|
'Freitag',
|
||||||
|
'Samstag',
|
||||||
|
'Sonntag',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const KAPAZITAET_FELDER = [
|
||||||
|
'kapazitaetMo',
|
||||||
|
'kapazitaetDi',
|
||||||
|
'kapazitaetMi',
|
||||||
|
'kapazitaetDo',
|
||||||
|
'kapazitaetFr',
|
||||||
|
'kapazitaetSa',
|
||||||
|
'kapazitaetSo',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type KapazitaetFeld = (typeof KAPAZITAET_FELDER)[number]
|
||||||
|
|
||||||
|
/** Hex-Farbe mit Raute, drei- oder sechsstellig. */
|
||||||
|
export const farbeSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, 'Bitte eine Farbe wie #2563eb angeben.')
|
||||||
|
.transform((f) => f.toLowerCase())
|
||||||
|
|
||||||
|
const uhrzeitSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine((w) => ausUhrzeit(w) !== null, 'Bitte eine Uhrzeit wie 08:00 angeben.')
|
||||||
|
.transform((w) => ausUhrzeit(w) as number)
|
||||||
|
|
||||||
|
/** Eine Tageskapazitaet: 0 bis 24 Stunden, in Minuten. */
|
||||||
|
const kapazitaetSchema = z.coerce
|
||||||
|
.number()
|
||||||
|
.int('Bitte ganze Minuten angeben.')
|
||||||
|
.min(0, 'Nicht weniger als 0 Minuten.')
|
||||||
|
.max(1440, 'Nicht mehr als 1440 Minuten, also 24 Stunden.')
|
||||||
|
|
||||||
|
export const bereichSchema = z
|
||||||
|
.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(2, 'Mindestens zwei Zeichen.')
|
||||||
|
.max(40, 'Höchstens 40 Zeichen.'),
|
||||||
|
farbe: farbeSchema,
|
||||||
|
reihenfolge: z.coerce.number().int().min(0).max(999).default(0),
|
||||||
|
aktiv: z.coerce.boolean().default(true),
|
||||||
|
arbeitsfensterVon: uhrzeitSchema,
|
||||||
|
arbeitsfensterBis: uhrzeitSchema,
|
||||||
|
kapazitaetMo: kapazitaetSchema,
|
||||||
|
kapazitaetDi: kapazitaetSchema,
|
||||||
|
kapazitaetMi: kapazitaetSchema,
|
||||||
|
kapazitaetDo: kapazitaetSchema,
|
||||||
|
kapazitaetFr: kapazitaetSchema,
|
||||||
|
kapazitaetSa: kapazitaetSchema,
|
||||||
|
kapazitaetSo: kapazitaetSchema,
|
||||||
|
})
|
||||||
|
.refine((b) => b.arbeitsfensterVon < b.arbeitsfensterBis, {
|
||||||
|
path: ['arbeitsfensterBis'],
|
||||||
|
message: 'Das Arbeitsfenster muss nach seinem Beginn enden.',
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(b) =>
|
||||||
|
KAPAZITAET_FELDER.every(
|
||||||
|
(feld) => b[feld] <= b.arbeitsfensterBis - b.arbeitsfensterVon,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
path: ['kapazitaetMo'],
|
||||||
|
message: 'Keine Tageskapazität darf länger sein als das Arbeitsfenster.',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type BereichEingabe = z.infer<typeof bereichSchema>
|
||||||
|
|
||||||
|
/** Summe der Wochenkapazitaet in Minuten. */
|
||||||
|
export function wochenKapazitaet(bereich: Record<KapazitaetFeld, number>): number {
|
||||||
|
return KAPAZITAET_FELDER.reduce((summe, feld) => summe + bereich[feld], 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kapazitaet des Wochentags, 0 = Montag. */
|
||||||
|
export function kapazitaetAmTag(
|
||||||
|
bereich: Record<KapazitaetFeld, number>,
|
||||||
|
wochentagIndex: number,
|
||||||
|
): number {
|
||||||
|
const feld = KAPAZITAET_FELDER[wochentagIndex]
|
||||||
|
return feld ? bereich[feld] : 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { bereichSchema } from '@/lib/bereich'
|
||||||
|
import { auditSchreiben } from '@/server/audit'
|
||||||
|
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server Actions fuer Bereiche.
|
||||||
|
*
|
||||||
|
* Jede prueft die Rolle selbst – die Seite tut es ebenfalls, aber eine Action
|
||||||
|
* ist ein eigener Einstiegspunkt und darf sich darauf nicht verlassen. Jede
|
||||||
|
* schreibende Aktion hinterlaesst einen Audit-Eintrag.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Rueckmeldung =
|
||||||
|
| { ok: true; meldung: string }
|
||||||
|
| { ok: false; fehler: string; felder?: Record<string, string> }
|
||||||
|
|
||||||
|
function feldFehler(fehler: z.ZodError): Record<string, string> {
|
||||||
|
const felder: Record<string, string> = {}
|
||||||
|
for (const problem of fehler.issues) {
|
||||||
|
const feld = problem.path.join('.')
|
||||||
|
felder[feld] ??= problem.message
|
||||||
|
}
|
||||||
|
return felder
|
||||||
|
}
|
||||||
|
|
||||||
|
function alsFormularwerte(daten: FormData): Record<string, unknown> {
|
||||||
|
const werte = Object.fromEntries(daten.entries())
|
||||||
|
return {
|
||||||
|
...werte,
|
||||||
|
// Ein nicht angehaktes Kaestchen taucht im FormData gar nicht auf.
|
||||||
|
aktiv: daten.get('aktiv') !== null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bereichAnlegen(daten: FormData): Promise<Rueckmeldung> {
|
||||||
|
let akteur
|
||||||
|
try {
|
||||||
|
akteur = await pruefeRolle('ADMIN')
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message }
|
||||||
|
throw fehler
|
||||||
|
}
|
||||||
|
|
||||||
|
const gelesen = bereichSchema.safeParse(alsFormularwerte(daten))
|
||||||
|
if (!gelesen.success) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
fehler: 'Bitte die markierten Felder prüfen.',
|
||||||
|
felder: feldFehler(gelesen.error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorhanden = await db.bereich.findUnique({
|
||||||
|
where: { name: gelesen.data.name },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (vorhanden) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
fehler: 'Diesen Namen gibt es schon.',
|
||||||
|
felder: { name: 'Diesen Namen gibt es schon.' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bereich = await db.bereich.create({
|
||||||
|
data: gelesen.data,
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: akteur.id, email: akteur.email },
|
||||||
|
aktion: 'bereich.angelegt',
|
||||||
|
objektTyp: 'Bereich',
|
||||||
|
objektId: bereich.id,
|
||||||
|
details: { name: bereich.name, farbe: gelesen.data.farbe },
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/bereiche')
|
||||||
|
return { ok: true, meldung: `Bereich „${bereich.name}" angelegt.` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bereichAendern(id: string, daten: FormData): Promise<Rueckmeldung> {
|
||||||
|
let akteur
|
||||||
|
try {
|
||||||
|
akteur = await pruefeRolle('ADMIN')
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message }
|
||||||
|
throw fehler
|
||||||
|
}
|
||||||
|
|
||||||
|
const gelesen = bereichSchema.safeParse(alsFormularwerte(daten))
|
||||||
|
if (!gelesen.success) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
fehler: 'Bitte die markierten Felder prüfen.',
|
||||||
|
felder: feldFehler(gelesen.error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorher = await db.bereich.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
if (!vorher) return { ok: false, fehler: 'Diesen Bereich gibt es nicht mehr.' }
|
||||||
|
|
||||||
|
const namensdublette = await db.bereich.findFirst({
|
||||||
|
where: { name: gelesen.data.name, id: { not: id } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (namensdublette) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
fehler: 'Diesen Namen gibt es schon.',
|
||||||
|
felder: { name: 'Diesen Namen gibt es schon.' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.bereich.update({ where: { id }, data: gelesen.data })
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: akteur.id, email: akteur.email },
|
||||||
|
aktion: 'bereich.geaendert',
|
||||||
|
objektTyp: 'Bereich',
|
||||||
|
objektId: id,
|
||||||
|
details: { vorher: vorher.name, nachher: gelesen.data.name },
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/bereiche')
|
||||||
|
return { ok: true, meldung: `Bereich „${gelesen.data.name}" gespeichert.` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const loeschEingabe = z.object({ id: z.string().min(1) })
|
||||||
|
|
||||||
|
export async function bereichLoeschen(daten: FormData): Promise<Rueckmeldung> {
|
||||||
|
let akteur
|
||||||
|
try {
|
||||||
|
akteur = await pruefeRolle('ADMIN')
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message }
|
||||||
|
throw fehler
|
||||||
|
}
|
||||||
|
|
||||||
|
const gelesen = loeschEingabe.safeParse({ id: daten.get('id') })
|
||||||
|
if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' }
|
||||||
|
|
||||||
|
const bereich = await db.bereich.findUnique({
|
||||||
|
where: { id: gelesen.data.id },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
if (!bereich) return { ok: false, fehler: 'Diesen Bereich gibt es nicht mehr.' }
|
||||||
|
|
||||||
|
await db.bereich.delete({ where: { id: bereich.id } })
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: akteur.id, email: akteur.email },
|
||||||
|
aktion: 'bereich.geloescht',
|
||||||
|
objektTyp: 'Bereich',
|
||||||
|
objektId: bereich.id,
|
||||||
|
details: { name: bereich.name },
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/bereiche')
|
||||||
|
return { ok: true, meldung: `Bereich „${bereich.name}" gelöscht.` }
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { auditSchreiben } from '@/server/audit'
|
||||||
|
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
|
||||||
|
export type Rueckmeldung = { ok: true; meldung: string } | { ok: false; fehler: string }
|
||||||
|
|
||||||
|
const eingabe = z.object({ id: z.string().min(1) })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt einen Passkey. Jeder darf nur seine eigenen entfernen – deshalb
|
||||||
|
* geht die Benutzer-Kennung in die where-Bedingung und wird nicht nur
|
||||||
|
* nachträglich verglichen.
|
||||||
|
*/
|
||||||
|
export async function passkeyEntfernen(daten: FormData): Promise<Rueckmeldung> {
|
||||||
|
let akteur
|
||||||
|
try {
|
||||||
|
akteur = await pruefeRolle('BENUTZER')
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message }
|
||||||
|
throw fehler
|
||||||
|
}
|
||||||
|
|
||||||
|
const gelesen = eingabe.safeParse({ id: daten.get('id') })
|
||||||
|
if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' }
|
||||||
|
|
||||||
|
const passkey = await db.passkey.findFirst({
|
||||||
|
where: { id: gelesen.data.id, benutzerId: akteur.id },
|
||||||
|
select: { id: true, bezeichnung: true },
|
||||||
|
})
|
||||||
|
if (!passkey) return { ok: false, fehler: 'Diesen Passkey gibt es nicht mehr.' }
|
||||||
|
|
||||||
|
await db.passkey.delete({ where: { id: passkey.id } })
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: akteur.id, email: akteur.email },
|
||||||
|
aktion: 'passkey.entfernt',
|
||||||
|
objektTyp: 'Passkey',
|
||||||
|
objektId: passkey.id,
|
||||||
|
details: { bezeichnung: passkey.bezeichnung },
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/einstellungen')
|
||||||
|
return { ok: true, meldung: `Passkey „${passkey.bezeichnung}" entfernt.` }
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import type { Prisma } from '@/generated/prisma/client'
|
||||||
|
import { auditDetails } from '@/lib/audit'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { aktuelleInstanz } from '@/server/instanz'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nachweis jeder schreibenden Aktion. Die Details werden vorher gesaeubert
|
||||||
|
* (siehe @/lib/audit), damit keine Geheimnisse in der Datenbank landen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AuditAkteur = {
|
||||||
|
id: string | null
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuditEingabe = {
|
||||||
|
akteur: AuditAkteur
|
||||||
|
/** Was geschah, in der Form "objekt.verb", z. B. "bereich.angelegt". */
|
||||||
|
aktion: string
|
||||||
|
objektTyp: string
|
||||||
|
objektId?: string | null
|
||||||
|
details?: Record<string, unknown> | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt einen Audit-Eintrag. Schlaegt das fehl, darf die eigentliche
|
||||||
|
* Aktion nicht als geglueckt gelten – deshalb wird der Fehler weitergereicht
|
||||||
|
* und nicht verschluckt.
|
||||||
|
*/
|
||||||
|
export async function auditSchreiben(eingabe: AuditEingabe): Promise<void> {
|
||||||
|
const details = auditDetails(eingabe.details)
|
||||||
|
await db.auditEintrag.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: eingabe.akteur.id,
|
||||||
|
benutzerEmail: eingabe.akteur.email,
|
||||||
|
aktion: eingabe.aktion,
|
||||||
|
objektTyp: eingabe.objektTyp,
|
||||||
|
objektId: eingabe.objektId ?? null,
|
||||||
|
...(details ? { details: details as Prisma.InputJsonObject } : {}),
|
||||||
|
instanz: aktuelleInstanz(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user