Files
AfiS-Aktensystem/web/app/(app)/personalakten/[id]/PersonnelHead.tsx
T
Harlekin 55bcb0d95c Phase 5 abgeschlossen: Dateien mit vollständiger Prüfkette
Upload → quarantine/ → Typprüfung → ClamAV → Neukodierung → store/. Es gibt
keinen Pfad, auf dem eine Datei ungeprüft in store/ landet, und keinen
Schalter, der die Kette abschaltet.

Die Typprüfung liest Magic Bytes aus einer eigenen Signaturtabelle statt aus
file-type: bei einer Positivliste aus fünf Formaten hilft es nicht, hundert
erkennen zu können. Die Signatur muss an Position 0 stehen - ein Vorspann vor
%PDF- ist der klassische Weg, eine Datei für zwei Programme zugleich lesbar zu
machen. Wer trotzdem durchkommt, kommt als text/plain durch und wird unter der
Endung des erkannten Typs ausgeliefert, nicht unter der hochgeladenen.

Drei Ausgänge statt zwei: BLOCKED heisst "ist gefährlich", FAILED heisst
"wissen wir nicht". Nur FAILED lässt sich freigeben, und die Freigabe
wiederholt die Kette ohne den Scanner - Typprüfung und Neukodierung laufen
dabei mit. Ein fehlender clamd macht damit keine Datei sauber; ein Test hält
das fest.

Auslieferung nur über /api/files/[id], mit Sichtbarkeitsprüfung der
zugehörigen Akte, Protokoll ab Stufe 3 und den harten Kopfzeilen aus docs/07.
Vorschaubilder dürfen inline, weil sie serverseitig als WebP neu kodiert
wurden.

Das Lichtbild im Aktenblatt ist damit gefüllt: der Feldwert ist die Kennung
eines geprüften Anhangs, serverseitig zu einer Adresse aufgelöst.

/api/events liefert attachment.scanned über pg_notify - die Grundlage, auf der
Phase 6 den Aktenchat aufsetzt. Die Sichtbarkeit wird je Ereignis geprüft,
sonst verriete schon die Kennung die Existenz.

Nebenbei riss das Bündelbudget (121 kB gegen 120 erlaubte): lib/i18n/de.ts war
ein Objekt, und eine Client-Komponente zieht die ganze Datei mit. Die Texte
stehen jetzt in sechs Dateien nach Bereichen, de.ts setzt sie nur zusammen.
Grösste Route wieder bei 118 kB, und sie wächst nicht mehr mit jedem neuen
Bereich mit.

133 Tests im Web-Paket, 15 in der Datenbank, 28 e2e.
2026-09-02 20:27:22 +02:00

290 lines
8.4 KiB
TypeScript

'use client'
import { useActionState, useState, useTransition } from 'react'
import { caseText } from '@/lib/i18n/cases'
import { clearanceText, commonText } from '@/lib/i18n/common'
import { personnelText } from '@/lib/i18n/people'
import { StatusTags } from '@/components/StatusTags'
import {
reclassifyPersonnelAction,
updatePersonnelAction,
type ActionResult,
} from '../actions'
type File = {
id: string
displayName: string
rank: string | null
department: string | null
statusTags: string[]
note: string | null
classification: number
userId: string | null
}
/**
* Kopfdaten einer Personalakte: lesen und ändern in derselben Ansicht.
*
* Die Klassifizierung steht bewusst getrennt. Sie ändert, wer die Akte
* überhaupt sieht, und eine Herabstufung braucht eine Begründung — das gehört
* nicht in dasselbe Formular wie eine Korrektur der Dienstnummer.
*/
export function PersonnelHead({
file,
canEdit,
levels,
statusTags,
}: {
file: File
canEdit: boolean
levels: { level: number; name: string }[]
statusTags: { key: string; name: string; colorToken: string }[]
}) {
const [editing, setEditing] = useState(false)
const [state, action, pending] = useActionState<ActionResult | null, FormData>(
updatePersonnelAction,
null,
)
if (!editing) {
return (
<div>
<dl className="grid gap-x-8 gap-y-2 text-sm sm:grid-cols-2">
<Zeile label={personnelText.rank} value={file.rank} />
<Zeile label={personnelText.department} value={file.department} />
<Zeile
label={personnelText.linkedAccount}
value={file.userId ? file.userId : personnelText.noAccount}
mono={Boolean(file.userId)}
/>
</dl>
<div className="mt-3 flex flex-wrap items-baseline gap-3">
<span className="font-condensed text-xs text-text-dim">{personnelText.status}</span>
{file.statusTags.length > 0 ? (
<StatusTags tags={file.statusTags} known={statusTags} />
) : (
<span className="text-sm text-text-dim">{personnelText.statusNone}</span>
)}
</div>
{file.note ? (
<p className="mt-4 max-w-(--container-measure) border-l-(length:--spacing-edge) border-line pl-3 text-sm whitespace-pre-wrap">
{file.note}
</p>
) : null}
{canEdit ? (
<div className="mt-4 flex flex-wrap items-center gap-4">
<button
type="button"
onClick={() => setEditing(true)}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent"
>
{personnelText.edit}
</button>
<Reclassify id={file.id} current={file.classification} levels={levels} />
</div>
) : null}
</div>
)
}
return (
<form
action={action}
onSubmit={() => setEditing(false)}
className="max-w-(--container-measure) space-y-4"
>
<input type="hidden" name="id" value={file.id} />
<Feld id="displayName" label={personnelText.name} defaultValue={file.displayName} required />
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="rank" label={personnelText.rank} defaultValue={file.rank} />
<Feld id="department" label={personnelText.department} defaultValue={file.department} />
</div>
<fieldset>
<legend className="font-condensed text-2xs text-text-dim">{personnelText.status}</legend>
<div className="mt-2 flex flex-wrap gap-x-6 gap-y-2">
{statusTags.map((tag) => (
<label key={tag.key} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="statusTags"
value={tag.key}
defaultChecked={file.statusTags.includes(tag.key)}
/>
{tag.name}
</label>
))}
</div>
<p className="mt-1 text-2xs text-text-dim">{personnelText.statusHint}</p>
</fieldset>
<div>
<label htmlFor="note" className="font-condensed text-2xs text-text-dim">
{personnelText.note}
</label>
<textarea
id="note"
name="note"
rows={3}
defaultValue={file.note ?? ''}
className="mt-1 block w-full border border-line bg-raised px-3 py-2 text-sm"
/>
</div>
{state && !state.ok ? (
<p className="font-condensed text-2xs text-stamp-4">{state.error}</p>
) : null}
<div className="flex gap-3">
<button
type="submit"
disabled={pending}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent disabled:text-text-dim"
>
{pending ? commonText.loading : commonText.save}
</button>
<button
type="button"
onClick={() => setEditing(false)}
className="font-condensed text-2xs text-text-dim"
>
{commonText.cancel}
</button>
</div>
</form>
)
}
function Reclassify({
id,
current,
levels,
}: {
id: string
current: number
levels: { level: number; name: string }[]
}) {
const [open, setOpen] = useState(false)
const [to, setTo] = useState(current)
const [reason, setReason] = useState('')
const [error, setError] = useState<string | null>(null)
const [pending, startTransition] = useTransition()
const lowering = to < current
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="font-condensed text-2xs text-text-dim"
>
{personnelText.reclassify}
</button>
)
}
return (
<div className="w-full border border-line p-3">
<div className="flex flex-wrap items-center gap-3">
<select
value={to}
onChange={(event) => setTo(Number(event.target.value))}
aria-label={clearanceText.classification}
className="border border-line bg-raised px-2 py-1 text-2xs"
>
{levels.map((level) => (
<option key={level.level} value={level.level}>
{level.level} · {level.name}
</option>
))}
</select>
<button
type="button"
disabled={pending || to === current}
onClick={() =>
startTransition(async () => {
const result = await reclassifyPersonnelAction({ id, to, reason })
if (result.ok) setOpen(false)
else setError(result.error)
})
}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent disabled:text-text-dim"
>
{commonText.save}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="font-condensed text-2xs text-text-dim"
>
{commonText.cancel}
</button>
</div>
{/* Nur die Herabstufung braucht eine Begründung: sie macht die Akte für
Leute sichtbar, die sie vorher nicht sehen durften. */}
{lowering ? (
<textarea
value={reason}
onChange={(event) => setReason(event.target.value)}
rows={2}
placeholder={caseText.reclassifyReason}
aria-label={caseText.reclassifyReason}
className="mt-2 block w-full border border-line bg-raised px-3 py-2 text-2xs"
/>
) : null}
{error ? <p className="mt-2 font-condensed text-2xs text-stamp-4">{error}</p> : null}
</div>
)
}
function Zeile({
label,
value,
mono,
}: {
label: string
value: string | null
mono?: boolean
}) {
return (
<div className="flex gap-3">
<dt className="font-condensed text-xs text-text-dim">{label}</dt>
<dd className={mono ? 'font-mono text-xs' : ''}>{value || '—'}</dd>
</div>
)
}
function Feld({
id,
label,
defaultValue,
required,
}: {
id: string
label: string
defaultValue?: string | null
required?: boolean
}) {
return (
<div>
<label htmlFor={id} className="font-condensed text-2xs text-text-dim">
{label}
</label>
<input
id={id}
name={id}
required={required}
minLength={required ? 2 : undefined}
maxLength={120}
defaultValue={defaultValue ?? ''}
className="mt-1 block w-full border border-line bg-raised px-3 py-2 text-sm"
/>
</div>
)
}