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(record.access_grants); const [targetType, setTargetType] = useState("user"); const [targetId, setTargetId] = useState(""); const [permission, setPermission] = useState("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 { 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 ( <> } >
{error ? ( {error} ) : null}

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.

setAllowedPurposes(event.target.value)} placeholder="cases.casework, cases.search" /> } 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} />
{grants.length === 0 ? (

No explicit access grants.

) : grants.map((grant) => (
{humanize(grant.subject_kind)} {grant.subject_id} setGrants((current) => current.map((item) => item === grant ? { ...item, allowed_purposes: splitPurposes(event.target.value) } : item ))} /> } variant="danger" disabled={busy} helpContextId="cases.detail.access" onClick={() => setGrants((current) => current.filter((item) => item !== grant))} />
))}
setChangeReason(event.target.value)} placeholder="Why is case access changing?" />
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))); }