feat(cases): enforce purpose-bound case governance
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-24 20:05:31 +02:00
parent 9435116299
commit 17415d8831
28 changed files with 786 additions and 142 deletions
+42 -9
View File
@@ -33,12 +33,14 @@ 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;
@@ -48,6 +50,7 @@ export default function CaseShareDialog({
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("");
@@ -55,8 +58,8 @@ export default function CaseShareDialog({
const idempotencyKey = useRef(crypto.randomUUID());
const { requestDiscard } = useUnsavedChanges();
const targetProvider = useMemo(
() => caseShareTargetProvider(settings, record.reference.object_id, targetType),
[record.reference.object_id, settings, targetType]
() => caseShareTargetProvider(settings, record.reference.object_id, targetType, purpose),
[purpose, record.reference.object_id, settings, targetType]
);
useEffect(() => {
@@ -66,11 +69,12 @@ export default function CaseShareDialog({
setTargetType("user");
setTargetId("");
setPermission("read");
setAllowedPurposes(purpose);
setChangeReason("");
setError("");
setConfirmOpen(false);
idempotencyKey.current = crypto.randomUUID();
}, [open, record]);
}, [open, purpose, record]);
const changed = restricted !== (record.access_mode === "restricted")
|| JSON.stringify(grants) !== JSON.stringify(record.access_grants);
@@ -82,13 +86,15 @@ export default function CaseShareDialog({
setTargetType("user");
setTargetId("");
setPermission("read");
setAllowedPurposes(purpose);
setChangeReason("");
setError("");
}
function addGrant() {
const subjectId = targetId.trim();
if (!subjectId) return;
const purposes = splitPurposes(allowedPurposes);
if (!subjectId || purposes.length === 0) return;
const subjectKind = targetType === "user" ? "account" : "group";
setGrants((current) => [
...current.filter(
@@ -97,7 +103,8 @@ export default function CaseShareDialog({
{
subject_kind: subjectKind,
subject_id: subjectId,
permissions: [permission]
permissions: [permission],
allowed_purposes: purposes
}
]);
setTargetId("");
@@ -113,6 +120,7 @@ export default function CaseShareDialog({
recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(),
idempotency_key: idempotencyKey.current,
purpose,
access_mode: restricted ? "restricted" : "tenant",
access_grants: grants
});
@@ -169,6 +177,7 @@ export default function CaseShareDialog({
closeDisabled={busy}
portal
className="case-share-dialog"
helpContextId="cases.detail.access"
footer={
<>
<Button disabled={busy} onClick={close}>Cancel</Button>
@@ -176,6 +185,7 @@ export default function CaseShareDialog({
variant="primary"
disabledReason={saveDisabledReason}
onClick={() => setConfirmOpen(true)}
helpContextId="cases.detail.access"
>
{busy ? "Saving" : "Save access"}
</Button>
@@ -196,9 +206,9 @@ export default function CaseShareDialog({
onChange={setRestricted}
/>
<p className="case-share-explanation">
Tenant cases follow the Cases read permission. Restricted cases are visible only to
their creator, case administrators, assigned functions or units, and the explicit
grants below.
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">
@@ -237,11 +247,19 @@ export default function CaseShareDialog({
<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 : undefined}
disabledReason={busy ? CASES_I18N.saving : !targetId.trim() ? CASES_I18N.targetRequired : splitPurposes(allowedPurposes).length === 0 ? "Declare at least one allowed purpose." : undefined}
onClick={addGrant}
/>
</div>
@@ -273,11 +291,22 @@ export default function CaseShareDialog({
<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>
@@ -317,3 +346,7 @@ export default function CaseShareDialog({
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)));
}