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
@@ -21,12 +21,14 @@ import { CASES_FIELDS_DOCUMENTATION } from "./interfacePatterns";
export default function CaseDecisionDialog({
settings,
record,
purpose,
open,
onClose,
onSaved
}: {
settings: ApiSettings;
record: CaseRecord;
purpose: string;
open: boolean;
onClose: () => void;
onSaved: (result: CaseDecisionResult) => void;
@@ -82,7 +84,8 @@ export default function CaseDecisionDialog({
reasoning: reasoning.trim(),
conditions: conditions.split("\n").map((item) => item.trim()).filter(Boolean),
change_reason: changeReason.trim(),
idempotency_key: idempotencyKey.current
idempotency_key: idempotencyKey.current,
purpose
});
setConfirmOpen(false);
onSaved(result);
+23 -11
View File
@@ -1,6 +1,6 @@
import { Archive, ArrowLeft, ExternalLink, Save, Scale, Share2, Unlink } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router";
import { useParams, useSearchParams } from "react-router";
import { ActionToolbar,
ActionBlockerHint,
Button,
@@ -22,6 +22,7 @@ import { ActionToolbar,
type QuickAccessResult
} from "@govoplan/core-webui";
import {
DEFAULT_CASE_ACCESS_PURPOSE,
caseHistory,
caseTimeline,
getCase,
@@ -48,6 +49,8 @@ import {
export default function CaseDetailPage({ settings, auth }: PlatformRouteContext) {
const { caseId = "" } = useParams();
const [searchParams] = useSearchParams();
const accessPurpose = searchParams.get("purpose")?.trim() || DEFAULT_CASE_ACCESS_PURPOSE;
const navigate = useGuardedNavigate();
const [record, setRecord] = useState<CaseRecord | null>(null);
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
@@ -92,20 +95,20 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
tenantId: record.reference.tenant_id,
label: `${record.case_number} · ${record.title}`,
version: record.reference.version ?? String(record.revision),
path: `/cases/${encodeURIComponent(record.reference.object_id)}`
} : null, [record]);
path: `/cases/${encodeURIComponent(record.reference.object_id)}?purpose=${encodeURIComponent(accessPurpose)}`
} : null, [accessPurpose, record]);
useRegisterActiveObject(activeCase);
const load = useCallback((signal?: AbortSignal) => {
setLoading(true);
setError("");
return Promise.all([
getCase(settings, caseId, signal),
getCase(settings, caseId, accessPurpose, signal),
listCaseCatalog(settings, signal),
caseHistory(settings, caseId, signal),
caseTimeline(settings, caseId, signal),
caseHistory(settings, caseId, accessPurpose, signal),
caseTimeline(settings, caseId, accessPurpose, signal),
canReadDecisions
? listCaseDecisions(settings, caseId, signal)
? listCaseDecisions(settings, caseId, accessPurpose, signal)
: Promise.resolve({ decisions: [] as FormalDecision[] })
]).
then(([nextRecord, nextCatalog, nextHistory, nextTimeline, nextDecisions]) => {
@@ -119,7 +122,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
setChangeReason("");
}).
finally(() => setLoading(false));
}, [canReadDecisions, caseId, settings]);
}, [accessPurpose, canReadDecisions, caseId, settings]);
useEffect(() => {
const controller = new AbortController();
@@ -179,6 +182,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
recorded_at: new Date().toISOString(),
change_reason: `Linked ${reference.ownerModule}.${reference.kind} through Quick Access.`,
idempotency_key: crypto.randomUUID(),
purpose: accessPurpose,
reference: {
owner_module: reference.ownerModule,
kind: reference.kind,
@@ -203,7 +207,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
QUICK_ACCESS_RESULT_EVENT,
receiveReference
);
}, [canUpdate, caseId, draftDirty, load, record, settings]);
}, [accessPurpose, canUpdate, caseId, draftDirty, load, record, settings]);
function discardDraft() {
if (!record) return;
@@ -222,6 +226,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(),
idempotency_key: idempotencyKey.current,
purpose: accessPurpose,
...(title.trim() !== record.title ? { title: title.trim() } : {}),
...(status !== record.status_key ? { status_key: status } : {})
});
@@ -260,7 +265,8 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: `Unlinked ${evidence.owner_module} reference from the Case.`,
idempotency_key: crypto.randomUUID()
idempotency_key: crypto.randomUUID(),
purpose: accessPurpose
}
);
setRecord(saved);
@@ -296,7 +302,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<main className="cases-page">
<div className="case-detail-shell">
<ActionToolbar className="case-detail-toolbar">
<button type="button" className="btn btn-ghost" onClick={() => navigate("/cases")}>
<button type="button" className="btn btn-ghost" onClick={() => navigate(`/cases?purpose=${encodeURIComponent(accessPurpose)}`)}>
<ArrowLeft size={16} aria-hidden="true" />
Cases
</button>
@@ -325,6 +331,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
icon={<Share2 size={16} />}
disabledReason={!canShare ? CASES_I18N.shareReason : undefined}
onClick={() => setShareOpen(true)}
helpContextId="cases.detail.access"
/>
</div>
) : null}
@@ -404,6 +411,8 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<Fact label="Opened" value={formatDateTime(record.opened_at)} />
<Fact label="Deadline" value={formatDateTime(record.deadline_at)} />
<Fact label="Revision" value={String(record.revision)} />
<Fact label="Access purpose" value={accessPurpose} />
<Fact label="Access mode" value={humanize(record.access_mode)} />
<Fact label="Last change" value={record.change_reason} />
</div>
@@ -444,6 +453,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<li key={entry.event_id}>
<strong>{humanize(entry.event_type)}</strong>
<span>{entry.summary}</span>
<span>{entry.purpose}</span>
<time>{formatDateTime(entry.occurred_at)}</time>
</li>
)}
@@ -470,6 +480,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<CaseShareDialog
settings={settings}
record={record}
purpose={accessPurpose}
open={shareOpen}
onClose={() => setShareOpen(false)}
onSaved={(saved) => {
@@ -484,6 +495,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<CaseDecisionDialog
settings={settings}
record={record}
purpose={accessPurpose}
open={decisionOpen}
onClose={() => setDecisionOpen(false)}
onSaved={(result) => {
+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)));
}
+18 -2
View File
@@ -1,5 +1,6 @@
import { Search } from "lucide-react";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { useSearchParams } from "react-router";
import { Button,
DocumentationHelpLink,
DismissibleAlert,
@@ -18,6 +19,7 @@ import { Button,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
DEFAULT_CASE_ACCESS_PURPOSE,
listCaseCatalog,
listCases,
type CaseCatalog,
@@ -28,8 +30,12 @@ import { CASES_DOCUMENTATION, CASES_I18N } from "./interfacePatterns";
export default function CasesPage({ settings }: PlatformRouteContext) {
const navigate = useGuardedNavigate();
const [searchParams] = useSearchParams();
const initialPurpose = searchParams.get("purpose")?.trim() || DEFAULT_CASE_ACCESS_PURPOSE;
const [query, setQuery] = useState("");
const [submittedQuery, setSubmittedQuery] = useState("");
const [purpose, setPurpose] = useState(initialPurpose);
const [submittedPurpose, setSubmittedPurpose] = useState(initialPurpose);
const [status, setStatus] = useState("");
const [cases, setCases] = useState<CaseRecord[]>([]);
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
@@ -46,6 +52,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
listCases(settings, {
query: submittedQuery,
statuses: status ? [status] : undefined,
purpose: submittedPurpose,
limit: 200
}, controller.signal),
listCaseCatalog(settings, controller.signal)
@@ -62,7 +69,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
}).
finally(() => setLoading(false));
return () => controller.abort();
}, [reloadKey, settings, status, submittedQuery]);
}, [reloadKey, settings, status, submittedPurpose, submittedQuery]);
const statusLabels = useMemo(
() => new Map(catalog.statuses.map((item) => [item.status_key, item])),
@@ -76,6 +83,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
function submit(event: FormEvent) {
event.preventDefault();
setSubmittedQuery(query.trim());
setSubmittedPurpose(purpose.trim());
}
return (
@@ -96,6 +104,14 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
aria-label="Search cases"
placeholder="Search cases"
/>
<input
value={purpose}
onChange={(event) => setPurpose(event.target.value)}
aria-label="Case access purpose"
placeholder="Case access purpose"
data-help-context-id="cases.field.access-purpose"
required
/>
<Button
type="submit"
variant="primary"
@@ -135,7 +151,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
<SelectionListItem
selected={false}
key={item.reference.object_id}
onClick={() => navigate(`/cases/${encodeURIComponent(item.reference.object_id)}`)}>
onClick={() => navigate(`/cases/${encodeURIComponent(item.reference.object_id)}?purpose=${encodeURIComponent(submittedPurpose)}`)}>
<SelectionListItemContent title={item.title} description={`${item.case_number} · ${typeLabels.get(item.case_type_key) ?? humanize(item.case_type_key)} · ${formatDate(item.deadline_at)}`} />
<StatusBadge
status={statusDefinition?.terminal ? "inactive" : "active"}