Files
govoplan-cases/webui/src/features/cases/CaseShareDialog.tsx
T
zemion 17415d8831
Module Package Release / publish-packages (push) Successful in 12s
feat(cases): enforce purpose-bound case governance
2026-08-24 20:05:31 +02:00

353 lines
12 KiB
TypeScript

import { Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Button,
ConfirmDialog,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FormField,
IconButton,
ReferenceSelect,
ToggleSwitch,
i18nMessage,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings
} from "@govoplan/core-webui";
import {
caseShareTargetProvider,
updateCase,
type CaseGrant,
type CaseRecord
} from "../../api/cases";
import {
CASES_FIELDS_DOCUMENTATION,
CASES_I18N
} from "./interfacePatterns";
type TargetType = "user" | "group";
type Permission = CaseGrant["permissions"][number];
export default function CaseShareDialog({
settings,
record,
purpose,
open,
onClose,
onSaved
}: {
settings: ApiSettings;
record: CaseRecord;
purpose: string;
open: boolean;
onClose: () => void;
onSaved: (record: CaseRecord) => void;
}) {
const [restricted, setRestricted] = useState(record.access_mode === "restricted");
const [grants, setGrants] = useState<CaseGrant[]>(record.access_grants);
const [targetType, setTargetType] = useState<TargetType>("user");
const [targetId, setTargetId] = useState("");
const [permission, setPermission] = useState<Permission>("read");
const [allowedPurposes, setAllowedPurposes] = useState(purpose);
const [changeReason, setChangeReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [confirmOpen, setConfirmOpen] = useState(false);
const idempotencyKey = useRef(crypto.randomUUID());
const { requestDiscard } = useUnsavedChanges();
const targetProvider = useMemo(
() => caseShareTargetProvider(settings, record.reference.object_id, targetType, purpose),
[purpose, record.reference.object_id, settings, targetType]
);
useEffect(() => {
if (!open) return;
setRestricted(record.access_mode === "restricted");
setGrants(record.access_grants);
setTargetType("user");
setTargetId("");
setPermission("read");
setAllowedPurposes(purpose);
setChangeReason("");
setError("");
setConfirmOpen(false);
idempotencyKey.current = crypto.randomUUID();
}, [open, purpose, record]);
const changed = restricted !== (record.access_mode === "restricted")
|| JSON.stringify(grants) !== JSON.stringify(record.access_grants);
const draftDirty = changed || Boolean(targetId.trim() || changeReason.trim());
function discardDraft() {
setRestricted(record.access_mode === "restricted");
setGrants(record.access_grants);
setTargetType("user");
setTargetId("");
setPermission("read");
setAllowedPurposes(purpose);
setChangeReason("");
setError("");
}
function addGrant() {
const subjectId = targetId.trim();
const purposes = splitPurposes(allowedPurposes);
if (!subjectId || purposes.length === 0) return;
const subjectKind = targetType === "user" ? "account" : "group";
setGrants((current) => [
...current.filter(
(item) => !(item.subject_kind === subjectKind && item.subject_id === subjectId)
),
{
subject_kind: subjectKind,
subject_id: subjectId,
permissions: [permission],
allowed_purposes: purposes
}
]);
setTargetId("");
}
async function save(): Promise<boolean> {
if (!changed || !changeReason.trim()) return false;
setBusy(true);
setError("");
try {
const saved = await updateCase(settings, record.reference.object_id, {
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(),
idempotency_key: idempotencyKey.current,
purpose,
access_mode: restricted ? "restricted" : "tenant",
access_grants: grants
});
onSaved(saved);
setRestricted(saved.access_mode === "restricted");
setGrants(saved.access_grants);
setTargetId("");
setChangeReason("");
idempotencyKey.current = crypto.randomUUID();
return true;
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Case access could not be saved.");
return false;
} finally {
setBusy(false);
}
}
useUnsavedDraftGuard({
dirty: open && draftDirty,
title: "i18n:govoplan-cases.unsaved_access_title",
message: "i18n:govoplan-cases.unsaved_access_message",
onSave: save,
onDiscard: discardDraft
});
function close() {
if (busy) return;
if (draftDirty) requestDiscard(onClose);
else onClose();
}
async function confirmSave() {
const saved = await save();
if (!saved) return;
setConfirmOpen(false);
onClose();
}
const saveDisabledReason = busy
? CASES_I18N.saving
: !changed
? CASES_I18N.noChanges
: !changeReason.trim()
? CASES_I18N.incomplete
: undefined;
return (
<>
<Dialog
open={open}
title={i18nMessage("i18n:govoplan-cases.case_access_title", { value0: record.case_number })}
onClose={close}
closeDisabled={busy}
portal
className="case-share-dialog"
helpContextId="cases.detail.access"
footer={
<>
<Button disabled={busy} onClick={close}>Cancel</Button>
<Button
variant="primary"
disabledReason={saveDisabledReason}
onClick={() => setConfirmOpen(true)}
helpContextId="cases.detail.access"
>
{busy ? "Saving" : "Save access"}
</Button>
</>
}
>
<div className="case-share-content">
<DocumentationHelpLink reference={CASES_FIELDS_DOCUMENTATION} />
{error ? (
<DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>
) : null}
<ToggleSwitch
label="Case visibility"
inactiveLabel="Tenant"
activeLabel="Restricted"
checked={restricted}
disabled={busy}
onChange={setRestricted}
/>
<p className="case-share-explanation">
Tenant cases follow the Cases read permission. Restricted cases require a current
grant whose permission and exact allowed purpose both match the attempted action.
The creating account receives a purpose-bound custodian grant.
</p>
<div className="case-share-add-row">
<FormField label="Target type" documentation={CASES_FIELDS_DOCUMENTATION}>
<select
value={targetType}
disabled={busy}
onChange={(event) => {
setTargetType(event.target.value as TargetType);
setTargetId("");
}}
>
<option value="user">User</option>
<option value="group">Group</option>
</select>
</FormField>
<FormField label="Target" documentation={CASES_FIELDS_DOCUMENTATION}>
<ReferenceSelect
value={targetId}
onChange={setTargetId}
provider={targetProvider}
disabled={busy}
placeholder={`Select a ${targetType}`}
aria-label={`Case access ${targetType}`}
/>
</FormField>
<FormField label="Permission" documentation={CASES_FIELDS_DOCUMENTATION}>
<select
value={permission}
disabled={busy}
onChange={(event) => setPermission(event.target.value as Permission)}
>
<option value="read">Read</option>
<option value="update">Update</option>
<option value="share">Share</option>
<option value="admin">Administer</option>
</select>
</FormField>
<FormField label="Allowed purposes" helpContextId="cases.field.allowed-purposes" documentation={CASES_FIELDS_DOCUMENTATION}>
<input
value={allowedPurposes}
disabled={busy}
onChange={(event) => setAllowedPurposes(event.target.value)}
placeholder="cases.casework, cases.search"
/>
</FormField>
<IconButton
label="Add access grant"
icon={<Plus size={16} />}
variant="primary"
disabledReason={busy ? CASES_I18N.saving : !targetId.trim() ? CASES_I18N.targetRequired : splitPurposes(allowedPurposes).length === 0 ? "Declare at least one allowed purpose." : undefined}
onClick={addGrant}
/>
</div>
<div className="case-share-grants" aria-label="Explicit access grants">
{grants.length === 0 ? (
<p>No explicit access grants.</p>
) : grants.map((grant) => (
<div
key={`${grant.subject_kind}:${grant.subject_id}`}
className="case-share-grant"
>
<span>
<strong>{humanize(grant.subject_kind)}</strong>
{grant.subject_id}
</span>
<select
aria-label={`Permission for ${grant.subject_id}`}
value={grant.permissions[0] ?? "read"}
disabled={busy}
onChange={(event) => setGrants((current) => current.map((item) =>
item === grant
? { ...item, permissions: [event.target.value as Permission] }
: item
))}
>
<option value="read">Read</option>
<option value="update">Update</option>
<option value="share">Share</option>
<option value="admin">Administer</option>
</select>
<input
aria-label={`Allowed purposes for ${grant.subject_id}`}
value={grant.allowed_purposes.join(", ")}
disabled={busy}
onChange={(event) => setGrants((current) => current.map((item) =>
item === grant
? { ...item, allowed_purposes: splitPurposes(event.target.value) }
: item
))}
/>
<IconButton
label={`Remove access for ${grant.subject_id}`}
icon={<Trash2 size={16} />}
variant="danger"
disabled={busy}
helpContextId="cases.detail.access"
onClick={() => setGrants((current) => current.filter((item) => item !== grant))}
/>
</div>
))}
</div>
<FormField label="Change reason" documentation={CASES_FIELDS_DOCUMENTATION}>
<input
value={changeReason}
disabled={busy}
maxLength={1000}
onChange={(event) => setChangeReason(event.target.value)}
placeholder="Why is case access changing?"
/>
</FormField>
</div>
</Dialog>
<ConfirmDialog
open={confirmOpen}
title="i18n:govoplan-cases.access_confirm_title"
message={i18nMessage("i18n:govoplan-cases.access_confirm_message", {
value0: record.case_number,
value1: restricted
? "i18n:govoplan-cases.visibility_restricted"
: "i18n:govoplan-cases.visibility_tenant",
value2: grants.length
})}
confirmLabel="Save access"
busy={busy}
onCancel={() => setConfirmOpen(false)}
onConfirm={() => void confirmSave()}
/>
</>
);
}
function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function splitPurposes(value: string): string[] {
return Array.from(new Set(value.split(/[\n,]+/).map((item) => item.trim()).filter(Boolean)));
}