feat(records): enforce purpose-bound restricted access
Module Package Release / publish-packages (push) Successful in 13s

This commit is contained in:
2026-08-24 19:07:04 +02:00
parent ecfcdd1fea
commit a2dd280c47
24 changed files with 2058 additions and 123 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/records-webui",
"version": "0.1.22",
"version": "0.1.23",
"private": true,
"type": "module",
"main": "src/index.ts",
+54 -3
View File
@@ -165,6 +165,21 @@ export type RecordTransferPackage = {
recorded_at: string;
};
export type RecordAccessGrant = {
grant_id: string;
record_id: string;
revision: number;
status: "active" | "revoked" | string;
subject_type: "account" | "membership" | "group" | "role" | "function_assignment" | "delegation";
subject_id: string;
actions: Array<"read" | "write" | "manage">;
allowed_purposes: string[];
reason: string;
valid_from: string;
valid_to?: string | null;
recorded_at: string;
};
export type RecordDetail = {
record: RecordEntry;
volumes: RecordVolume[];
@@ -179,6 +194,8 @@ export type RecordDetail = {
purpose: string;
current_authorization: boolean;
access_mode: string;
grant_id?: string | null;
grant_subject_type?: string | null;
limitations: string[];
};
};
@@ -209,6 +226,7 @@ export function listRecords(
state?: string;
classId?: string;
filePlanNodeId?: string;
purpose?: string;
offset?: number;
limit?: number;
},
@@ -219,13 +237,19 @@ export function listRecords(
state: options.state,
class_id: options.classId,
file_plan_node_id: options.filePlanNodeId,
purpose: options.purpose,
offset: options.offset,
limit: options.limit ?? 50
}), { signal });
}
export function getRecord(settings: ApiSettings, recordId: string, signal?: AbortSignal): Promise<RecordDetail> {
return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}`, { signal });
export function getRecord(
settings: ApiSettings,
recordId: string,
purpose?: string,
signal?: AbortSignal
): Promise<RecordDetail> {
return apiFetch(settings, apiPath(`/api/v1/records/${encodeURIComponent(recordId)}`, { purpose }), { signal });
}
export function getRecordCatalog(settings: ApiSettings, signal?: AbortSignal): Promise<RecordCatalog> {
@@ -362,9 +386,36 @@ export function dispatchRecordTransfer(
export function getRecordRecoveryStatus(
settings: ApiSettings,
recordId: string,
purpose: string,
signal?: AbortSignal
): Promise<Record<string, unknown>> {
return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}/recovery`, { signal });
return apiFetch(settings, apiPath(`/api/v1/records/${encodeURIComponent(recordId)}/recovery`, { purpose }), { signal });
}
export function listRecordAccessGrants(
settings: ApiSettings,
recordId: string,
purpose: string,
signal?: AbortSignal
): Promise<{ grants: RecordAccessGrant[] }> {
return apiFetch(settings, apiPath(`/api/v1/records/${encodeURIComponent(recordId)}/access-grants`, { purpose }), { signal });
}
export function createRecordAccessGrant(
settings: ApiSettings,
recordId: string,
payload: Record<string, unknown>
): Promise<RecordAccessGrant> {
return recordMutation(settings, recordId, "access-grants", payload);
}
export function revokeRecordAccessGrant(
settings: ApiSettings,
recordId: string,
grantId: string,
payload: Record<string, unknown>
): Promise<RecordAccessGrant> {
return recordMutation(settings, recordId, `access-grants/${encodeURIComponent(grantId)}/revoke`, payload);
}
function recordMutation<T>(
@@ -0,0 +1,237 @@
import { useEffect, useState, type FormEvent } from "react";
import {
Button,
Dialog,
DialogForm,
DismissibleAlert,
FormField,
LoadingIndicator,
StatePanel,
StatusBadge,
type ApiSettings
} from "@govoplan/core-webui";
import {
createRecordAccessGrant,
listRecordAccessGrants,
revokeRecordAccessGrant,
type RecordAccessGrant,
type RecordEntry
} from "../../api/records";
type SubjectType = RecordAccessGrant["subject_type"];
export function RecordAccessGrantsDialog({
open,
settings,
record,
managementPurpose,
onClose,
onChanged
}: {
open: boolean;
settings: ApiSettings;
record: RecordEntry | null;
managementPurpose: string;
onClose: () => void;
onChanged: () => void;
}) {
const [grants, setGrants] = useState<RecordAccessGrant[]>([]);
const [subjectType, setSubjectType] = useState<SubjectType>("account");
const [subjectId, setSubjectId] = useState("");
const [read, setRead] = useState(true);
const [write, setWrite] = useState(false);
const [manage, setManage] = useState(false);
const [allowedPurposes, setAllowedPurposes] = useState("");
const [reason, setReason] = useState("");
const [validTo, setValidTo] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const reload = () => {
if (!open || !record || !managementPurpose.trim()) return;
const controller = new AbortController();
setLoading(true);
setError("");
listRecordAccessGrants(
settings,
record.record_id,
managementPurpose.trim(),
controller.signal
).then((result) => setGrants(result.grants)).catch((cause) => {
if ((cause as Error).name !== "AbortError") {
setError(errorMessage(cause, "The restricted access grants could not be loaded."));
}
}).finally(() => setLoading(false));
return () => controller.abort();
};
useEffect(reload, [managementPurpose, open, record?.record_id, settings]);
useEffect(() => {
if (!open) return;
setSubjectType("account");
setSubjectId("");
setRead(true);
setWrite(false);
setManage(false);
setAllowedPurposes("");
setReason("");
setValidTo("");
}, [open]);
async function submit(event: FormEvent) {
event.preventDefault();
if (!record) return;
const actions = [read ? "read" : "", write ? "write" : "", manage ? "manage" : ""].filter(Boolean);
const purposes = splitValues(allowedPurposes);
if (!subjectId.trim() || actions.length === 0 || purposes.length === 0 || !reason.trim()) return;
setSaving(true);
setError("");
try {
await createRecordAccessGrant(settings, record.record_id, {
subject_type: subjectType,
subject_id: subjectId.trim(),
actions,
allowed_purposes: purposes,
reason: reason.trim(),
purpose: managementPurpose.trim(),
recorded_at: new Date().toISOString(),
valid_to: validTo ? new Date(validTo).toISOString() : null,
institutional_context: {},
idempotency_key: randomId()
});
setSubjectId("");
setAllowedPurposes("");
setReason("");
setValidTo("");
onChanged();
reload();
} catch (cause) {
setError(errorMessage(cause, "The restricted access grant could not be created."));
} finally {
setSaving(false);
}
}
async function revoke(grant: RecordAccessGrant) {
if (!record || !window.confirm("Revoke this restricted-record access grant?")) return;
setSaving(true);
setError("");
try {
await revokeRecordAccessGrant(settings, record.record_id, grant.grant_id, {
expected_revision: grant.revision,
reason: "Access grant revoked by a Records administrator.",
purpose: managementPurpose.trim(),
recorded_at: new Date().toISOString(),
idempotency_key: randomId()
});
onChanged();
reload();
} catch (cause) {
setError(errorMessage(cause, "The restricted access grant could not be revoked."));
} finally {
setSaving(false);
}
}
const actionsSelected = read || write || manage;
const canSubmit = Boolean(
record && managementPurpose.trim() && subjectId.trim() && actionsSelected &&
splitValues(allowedPurposes).length && reason.trim()
);
return (
<Dialog
open={open}
title="Restricted record access"
onClose={onClose}
closeDisabled={saving}
portal
className="records-dialog records-access-dialog"
helpContextId="records.restricted-access"
footer={<Button type="button" variant="secondary" onClick={onClose} disabled={saving}>Close</Button>}
>
<p>
Grants are effective-dated and match an exact subject, action, and declared purpose.
Current grants also govern historical record views.
</p>
<p><strong>Management purpose:</strong> {managementPurpose || "Not supplied"}</p>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{loading ? <LoadingIndicator label="Loading access grants" /> : grants.length === 0 ? (
<StatePanel size="inline" description="No current access grants are available." />
) : (
<div className="records-grant-list">
{grants.map((grant) => (
<div className="records-grant-row" key={grant.grant_id}>
<div>
<strong>{humanize(grant.subject_type)} · {grant.subject_id}</strong>
<span>{grant.actions.map(humanize).join(", ")} · {grant.allowed_purposes.join(", ")}</span>
<small>{grant.reason}</small>
</div>
<StatusBadge status={grant.status === "active" ? "active" : "neutral"} label={humanize(grant.status)} />
{grant.status === "active" && (
<Button
type="button"
variant="danger"
onClick={() => revoke(grant)}
disabled={saving}
helpContextId="records.restricted-access"
helpModuleId="records"
>
Revoke
</Button>
)}
</div>
))}
</div>
)}
<DialogForm id="records-access-grant-form" className="records-dialog-form" onSubmit={submit}>
<h3>Add access grant</h3>
<FormField label="Subject type">
<select value={subjectType} onChange={(event) => setSubjectType(event.target.value as SubjectType)}>
<option value="account">Account</option>
<option value="membership">Membership</option>
<option value="group">Group</option>
<option value="role">Role</option>
<option value="function_assignment">Function assignment</option>
<option value="delegation">Delegation</option>
</select>
</FormField>
<FormField label="Subject identifier"><input value={subjectId} onChange={(event) => setSubjectId(event.target.value)} required /></FormField>
<FormField label="Actions">
<div className="records-grant-actions">
<label><input type="checkbox" checked={read} onChange={(event) => setRead(event.target.checked)} /> Read</label>
<label><input type="checkbox" checked={write} onChange={(event) => setWrite(event.target.checked)} /> Write</label>
<label><input type="checkbox" checked={manage} onChange={(event) => setManage(event.target.checked)} /> Manage</label>
</div>
</FormField>
<FormField label="Allowed purposes"><textarea value={allowedPurposes} onChange={(event) => setAllowedPurposes(event.target.value)} rows={3} placeholder="One purpose per line" required /></FormField>
<FormField label="Valid until"><input type="datetime-local" value={validTo} onChange={(event) => setValidTo(event.target.value)} /></FormField>
<FormField label="Reason"><textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} required /></FormField>
<Button type="submit" variant="primary" disabledReason={saving ? "The grant is being saved." : !canSubmit ? "Complete the subject, action, purpose, and reason." : undefined}>
{saving ? "Saving" : "Add grant"}
</Button>
</DialogForm>
</Dialog>
);
}
function splitValues(value: string): string[] {
return Array.from(new Set(value.split(/[\n,]/).map((item) => item.trim()).filter(Boolean)));
}
function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function randomId(): string {
return typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function errorMessage(reason: unknown, fallback: string): string {
return reason instanceof Error && reason.message ? reason.message : fallback;
}
@@ -122,7 +122,7 @@ export function RecordLifecyclePanel({
openAction("recovery");
setSaving(true);
try {
setRecovery(await getRecordRecoveryStatus(settings, record.record_id));
setRecovery(await getRecordRecoveryStatus(settings, record.record_id, purpose.trim()));
} catch (reason) {
setError(errorMessage(reason, "Recovery evidence could not be loaded."));
} finally {
+99 -7
View File
@@ -51,6 +51,7 @@ import {
type RecordSourceProvider
} from "../../api/records";
import { RecordCatalogDialog } from "./RecordCatalogDialog";
import { RecordAccessGrantsDialog } from "./RecordAccessGrantsDialog";
import { RecordLifecyclePanel } from "./RecordLifecyclePanel";
import {
RECORDS_DOCUMENTATION,
@@ -78,6 +79,12 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
const [page, setPage] = useState(1);
const [query, setQuery] = useState("");
const [submittedQuery, setSubmittedQuery] = useState("");
const [accessPurpose, setAccessPurpose] = useState(
() => searchParams.get("purpose") ?? ""
);
const [submittedAccessPurpose, setSubmittedAccessPurpose] = useState(
() => searchParams.get("purpose") ?? ""
);
const [selectedNodeId, setSelectedNodeId] = useState("");
const [selectedRecordId, setSelectedRecordId] = useState(
() => searchParams.get("recordId") ?? ""
@@ -94,6 +101,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
const [editOpen, setEditOpen] = useState(false);
const [fileOpen, setFileOpen] = useState(false);
const [catalogOpen, setCatalogOpen] = useState(false);
const [accessGrantsOpen, setAccessGrantsOpen] = useState(false);
const canWrite = auth.scopes.includes("records:workspace:write") ||
auth.scopes.includes("records:workspace:admin");
const canAdmin = auth.scopes.includes("records:workspace:admin");
@@ -133,6 +141,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
listRecords(settings, {
query: submittedQuery,
filePlanNodeId: selectedNodeId || undefined,
purpose: submittedAccessPurpose || undefined,
offset: (page - 1) * PAGE_SIZE,
limit: PAGE_SIZE
}, controller.signal).
@@ -153,7 +162,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
}).
finally(() => setLoading(false));
return () => controller.abort();
}, [page, reloadKey, selectedNodeId, selectionKey, settings, submittedQuery]);
}, [page, reloadKey, selectedNodeId, selectionKey, settings, submittedAccessPurpose, submittedQuery]);
useEffect(() => {
if (!selectedRecordId) {
@@ -164,7 +173,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
const controller = new AbortController();
setDetailLoading(true);
setDetailError("");
getRecord(settings, selectedRecordId, controller.signal).
getRecord(settings, selectedRecordId, submittedAccessPurpose || undefined, controller.signal).
then(setDetail).
catch((reason) => {
if ((reason as Error).name !== "AbortError") {
@@ -174,7 +183,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
}).
finally(() => setDetailLoading(false));
return () => controller.abort();
}, [reloadKey, selectedRecordId, selectionKey, settings]);
}, [reloadKey, selectedRecordId, selectionKey, settings, submittedAccessPurpose]);
useEffect(() => {
const current = searchParams.get("recordId") ?? "";
@@ -182,8 +191,10 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
const next = new URLSearchParams(searchParams);
if (selectedRecordId) next.set("recordId", selectedRecordId);
else next.delete("recordId");
if (submittedAccessPurpose) next.set("purpose", submittedAccessPurpose);
else next.delete("purpose");
setSearchParams(next, { replace: true });
}, [searchParams, selectedRecordId, setSearchParams]);
}, [searchParams, selectedRecordId, setSearchParams, submittedAccessPurpose]);
const classesById = useMemo(
() => new Map(catalog.classes.map((item) => [item.class_id, item])),
@@ -231,6 +242,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
event.preventDefault();
setPage(1);
setSubmittedQuery(query.trim());
setSubmittedAccessPurpose(accessPurpose.trim());
}
return (
@@ -267,6 +279,12 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
aria-label="Search records"
placeholder="Search records"
/>
<input
value={accessPurpose}
onChange={(event) => setAccessPurpose(event.target.value)}
aria-label="Access purpose"
placeholder="Access purpose"
/>
</FilterBar>
<span className="records-result-count">
{i18nMessage("i18n:govoplan-records.record_count", { value0: total })}
@@ -376,9 +394,11 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
archiveProviders={archiveProviders}
canWrite={canWrite}
canAdmin={canAdmin}
accessPurpose={submittedAccessPurpose}
hasSources={sources.length > 0}
onEdit={() => setEditOpen(true)}
onFile={() => setFileOpen(true)}
onManageAccess={() => setAccessGrantsOpen(true)}
onChanged={reload}
/>
)}
@@ -391,11 +411,16 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
mode="create"
settings={settings}
catalog={catalog}
canAdmin={canAdmin}
onClose={() => setCreateOpen(false)}
onSaved={(saved) => {
setCreateOpen(false);
setSelectedNodeId("");
setSelectedRecordId(saved.record_id);
if (saved.access_mode === "restricted") {
setAccessPurpose(saved.purpose);
setSubmittedAccessPurpose(saved.purpose);
}
reload();
}}
/>
@@ -404,6 +429,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
mode="edit"
settings={settings}
catalog={catalog}
canAdmin={canAdmin}
record={detail?.record ?? null}
onClose={() => setEditOpen(false)}
onSaved={(saved) => {
@@ -436,6 +462,14 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
reload();
}}
/>
<RecordAccessGrantsDialog
open={accessGrantsOpen}
settings={settings}
record={detail?.record ?? null}
managementPurpose={submittedAccessPurpose}
onClose={() => setAccessGrantsOpen(false)}
onChanged={reload}
/>
</main>
);
}
@@ -447,9 +481,11 @@ function RecordDetailPanel({
archiveProviders,
canWrite,
canAdmin,
accessPurpose,
hasSources,
onEdit,
onFile,
onManageAccess,
onChanged
}: {
detail: RecordDetail;
@@ -458,9 +494,11 @@ function RecordDetailPanel({
archiveProviders: RecordArchiveProvider[];
canWrite: boolean;
canAdmin: boolean;
accessPurpose: string;
hasSources: boolean;
onEdit: () => void;
onFile: () => void;
onManageAccess: () => void;
onChanged: () => void;
}) {
const record = detail.record;
@@ -473,6 +511,16 @@ function RecordDetailPanel({
<h1>{record.title}</h1>
</div>
<div className="records-detail-actions">
{record.access_mode === "restricted" && canAdmin && (
<Button
type="button"
variant="ghost"
onClick={onManageAccess}
disabledReason={!accessPurpose ? "Enter the management purpose in the access-purpose filter first." : undefined}
>
Manage access
</Button>
)}
<Button type="button" variant="ghost" onClick={onEdit} disabledReason={!canWrite ? "Your account may not revise records." : undefined}>
<Pencil size={16} aria-hidden="true" />
Edit
@@ -576,6 +624,11 @@ function RecordDetailPanel({
<p>{detail.access_explanation.reason}</p>
<dl>
<div><dt>Record purpose</dt><dd>{record.purpose}</dd></div>
<div><dt>Access mode</dt><dd>{humanize(record.access_mode)}</dd></div>
<div><dt>Evaluated purpose</dt><dd>{detail.access_explanation.purpose || "Tenant access"}</dd></div>
{detail.access_explanation.grant_subject_type && (
<div><dt>Matched grant</dt><dd>{humanize(detail.access_explanation.grant_subject_type)}</dd></div>
)}
<div><dt>Authorization</dt><dd>{detail.access_explanation.current_authorization ? "Current authorization applied" : "Not evaluated"}</dd></div>
</dl>
{detail.access_explanation.limitations.map((limitation) => (
@@ -591,6 +644,7 @@ function RecordDialog({
mode,
settings,
catalog,
canAdmin,
record,
onClose,
onSaved
@@ -599,6 +653,7 @@ function RecordDialog({
mode: "create" | "edit";
settings: PlatformRouteContext["settings"];
catalog: RecordCatalog;
canAdmin: boolean;
record?: RecordEntry | null;
onClose: () => void;
onSaved: (record: RecordEntry) => void;
@@ -611,6 +666,8 @@ function RecordDialog({
const [state, setState] = useState<"planned" | "open">("open");
const [classification, setClassification] = useState("");
const [purpose, setPurpose] = useState("");
const [accessMode, setAccessMode] = useState<"tenant" | "restricted">("tenant");
const [initialPurposes, setInitialPurposes] = useState("");
const [changeReason, setChangeReason] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
@@ -624,15 +681,22 @@ function RecordDialog({
setState(record?.state === "planned" ? "planned" : "open");
setClassification(record?.classification ?? "");
setPurpose(record?.purpose ?? "");
setAccessMode(record?.access_mode ?? firstClass?.access_mode ?? "tenant");
setInitialPurposes(record?.access_mode === "restricted" ? record.purpose : "");
setChangeReason("");
setError("");
}, [firstClass?.class_id, open, record]);
const selectedClass = catalog.classes.find((item) => item.class_id === classId);
const effectiveAccessMode = selectedClass?.access_mode === "restricted" ? "restricted" : accessMode;
const needsInitialPurposes = effectiveAccessMode === "restricted" &&
(mode === "create" || record?.access_mode !== "restricted");
const parsedInitialPurposes = splitValues(initialPurposes);
const restrictedPurposeMissing = needsInitialPurposes && !parsedInitialPurposes.includes(purpose.trim());
async function submit(event: FormEvent) {
event.preventDefault();
if (!selectedClass || !title.trim() || !purpose.trim() || !changeReason.trim() || (mode === "create" && !recordNumber.trim())) return;
if (!selectedClass || !title.trim() || !purpose.trim() || !changeReason.trim() || restrictedPurposeMissing || (mode === "create" && !recordNumber.trim())) return;
setSaving(true);
setError("");
try {
@@ -644,6 +708,10 @@ function RecordDialog({
...(mode === "create" || record?.state === "planned" || record?.state === "open" ? { state } : {}),
classification: classification.trim() || null,
purpose: purpose.trim(),
...(mode === "create" || effectiveAccessMode !== record?.access_mode
? { access_mode: effectiveAccessMode }
: {}),
...(needsInitialPurposes ? { initial_purposes: parsedInitialPurposes } : {}),
recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(),
idempotency_key: randomId()
@@ -653,7 +721,6 @@ function RecordDialog({
...common,
record_number: recordNumber.trim(),
source_authority_mode: "native_authoritative",
access_mode: "tenant",
valid_from: new Date().toISOString(),
institutional_context: {}
})
@@ -681,7 +748,7 @@ function RecordDialog({
footer={
<>
<Button type="button" variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button>
<Button type="submit" form="records-record-form" variant="primary" disabledReason={saving ? "The record is being saved." : !selectedClass || !title.trim() || !purpose.trim() || !changeReason.trim() || (mode === "create" && !recordNumber.trim()) ? "Complete all required record fields." : undefined}>
<Button type="submit" form="records-record-form" variant="primary" disabledReason={saving ? "The record is being saved." : restrictedPurposeMissing ? "Include the action purpose in the initial allowed purposes." : !selectedClass || !title.trim() || !purpose.trim() || !changeReason.trim() || (mode === "create" && !recordNumber.trim()) ? "Complete all required record fields." : undefined}>
{saving ? "Saving" : "Save record"}
</Button>
</>
@@ -716,6 +783,27 @@ function RecordDialog({
<FormField label="Purpose" documentation={RECORDS_FIELD_DOCUMENTATION.purpose}>
<input value={purpose} onChange={(event) => setPurpose(event.target.value)} required />
</FormField>
<FormField label="Access mode" helpContextId="records.field.access-mode">
<select
value={effectiveAccessMode}
onChange={(event) => setAccessMode(event.target.value as "tenant" | "restricted")}
disabled={selectedClass?.access_mode === "restricted" || (mode === "edit" && !canAdmin)}
>
<option value="tenant">Tenant-wide permission</option>
<option value="restricted">Purpose-bound grants</option>
</select>
</FormField>
{needsInitialPurposes && (
<FormField label="Initial allowed purposes" helpContextId="records.field.initial-purposes">
<textarea
value={initialPurposes}
onChange={(event) => setInitialPurposes(event.target.value)}
rows={3}
placeholder="One purpose per line"
required
/>
</FormField>
)}
<FormField label="Description" helpContextId="records.field.description">
<textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={4} />
</FormField>
@@ -928,6 +1016,10 @@ function humanize(value: string): string {
return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function splitValues(value: string): string[] {
return Array.from(new Set(value.split(/[\n,]/).map((item) => item.trim()).filter(Boolean)));
}
function randomId(): string {
return typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
+86 -2
View File
@@ -166,7 +166,49 @@ const en = {
"Enter a reason for this action.": "Enter a reason for this action.",
"Enter the hold authority.": "Enter the hold authority.",
"Enter a volume label.": "Enter a volume label.",
"Select an available archive profile.": "Select an available archive profile."
"Select an available archive profile.": "Select an available archive profile.",
"Access purpose": "Access purpose",
"Manage access": "Manage access",
"Enter the management purpose in the access-purpose filter first.": "Enter the management purpose in the access-purpose filter first.",
"Access mode": "Access mode",
"Evaluated purpose": "Evaluated purpose",
"Tenant access": "Tenant access",
"Matched grant": "Matched grant",
"Tenant-wide permission": "Tenant-wide permission",
"Purpose-bound grants": "Purpose-bound grants",
"Initial allowed purposes": "Initial allowed purposes",
"One purpose per line": "One purpose per line",
"Include the action purpose in the initial allowed purposes.": "Include the action purpose in the initial allowed purposes.",
"Restricted record access": "Restricted record access",
"Management purpose:": "Management purpose:",
"Not supplied": "Not supplied",
"Loading access grants": "Loading access grants",
"No current access grants are available.": "No current access grants are available.",
"Add access grant": "Add access grant",
"Subject type": "Subject type",
"Subject identifier": "Subject identifier",
"Actions": "Actions",
"Allowed purposes": "Allowed purposes",
"Valid until": "Valid until",
"Read": "Read",
"Write": "Write",
"Manage": "Manage",
"Account": "Account",
"Membership": "Membership",
"Group": "Group",
"Role": "Role",
"Function assignment": "Function assignment",
"Delegation": "Delegation",
"Revoke": "Revoke",
"Add grant": "Add grant",
"The restricted access grants could not be loaded.": "The restricted access grants could not be loaded.",
"The restricted access grant could not be created.": "The restricted access grant could not be created.",
"The restricted access grant could not be revoked.": "The restricted access grant could not be revoked.",
"The grant is being saved.": "The grant is being saved.",
"Complete the subject, action, purpose, and reason.": "Complete the subject, action, purpose, and reason.",
"Grants are effective-dated and match an exact subject, action, and declared purpose. Current grants also govern historical record views.": "Grants are effective-dated and match an exact subject, action, and declared purpose. Current grants also govern historical record views.",
"Active": "Active",
"Revoked": "Revoked"
} as const;
const de: Record<keyof typeof en, string> = {
@@ -334,7 +376,49 @@ const de: Record<keyof typeof en, string> = {
"Enter a reason for this action.": "Geben Sie eine Begründung für diese Aktion an.",
"Enter the hold authority.": "Geben Sie die anordnende Stelle der Sperre an.",
"Enter a volume label.": "Geben Sie eine Bandbezeichnung an.",
"Select an available archive profile.": "Wählen Sie ein verfügbares Archivprofil."
"Select an available archive profile.": "Wählen Sie ein verfügbares Archivprofil.",
"Access purpose": "Zugriffszweck",
"Manage access": "Zugriff verwalten",
"Enter the management purpose in the access-purpose filter first.": "Geben Sie zuerst den Verwaltungszweck im Zugriffszweckfilter an.",
"Access mode": "Zugriffsmodus",
"Evaluated purpose": "Geprüfter Zweck",
"Tenant access": "Mandantenzugriff",
"Matched grant": "Passende Freigabe",
"Tenant-wide permission": "Mandantenweite Berechtigung",
"Purpose-bound grants": "Zweckgebundene Freigaben",
"Initial allowed purposes": "Anfänglich erlaubte Zwecke",
"One purpose per line": "Ein Zweck pro Zeile",
"Include the action purpose in the initial allowed purposes.": "Nehmen Sie den Aktionszweck in die anfänglich erlaubten Zwecke auf.",
"Restricted record access": "Zugriff auf besonders geschützte Akten",
"Management purpose:": "Verwaltungszweck:",
"Not supplied": "Nicht angegeben",
"Loading access grants": "Zugriffsfreigaben werden geladen",
"No current access grants are available.": "Es sind keine aktuellen Zugriffsfreigaben vorhanden.",
"Add access grant": "Zugriffsfreigabe hinzufügen",
"Subject type": "Subjekttyp",
"Subject identifier": "Subjektkennung",
"Actions": "Aktionen",
"Allowed purposes": "Erlaubte Zwecke",
"Valid until": "Gültig bis",
"Read": "Lesen",
"Write": "Schreiben",
"Manage": "Verwalten",
"Account": "Konto",
"Membership": "Mitgliedschaft",
"Group": "Gruppe",
"Role": "Rolle",
"Function assignment": "Funktionszuordnung",
"Delegation": "Delegation",
"Revoke": "Entziehen",
"Add grant": "Freigabe hinzufügen",
"The restricted access grants could not be loaded.": "Die Freigaben für besonders geschützte Akten konnten nicht geladen werden.",
"The restricted access grant could not be created.": "Die Freigabe für die besonders geschützte Akte konnte nicht angelegt werden.",
"The restricted access grant could not be revoked.": "Die Freigabe für die besonders geschützte Akte konnte nicht entzogen werden.",
"The grant is being saved.": "Die Freigabe wird gespeichert.",
"Complete the subject, action, purpose, and reason.": "Vervollständigen Sie Subjekt, Aktion, Zweck und Begründung.",
"Grants are effective-dated and match an exact subject, action, and declared purpose. Current grants also govern historical record views.": "Freigaben sind zeitlich wirksam und gelten für ein eindeutiges Subjekt, eine Aktion und einen erklärten Zweck. Aktuelle Freigaben bestimmen auch den Zugriff auf historische Aktenansichten.",
"Active": "Aktiv",
"Revoked": "Entzogen"
};
export const generatedTranslations: PlatformTranslations = { en, de };
+41
View File
@@ -553,6 +553,43 @@
grid-column: 1 / -1;
}
.records-grant-list {
display: grid;
gap: 8px;
margin-block: 16px;
}
.records-grant-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 12px;
align-items: center;
padding: 12px;
border: var(--border-line);
border-radius: var(--radius-sm);
}
.records-grant-row > div,
.records-grant-actions {
display: flex;
gap: 8px;
}
.records-grant-row > div {
min-width: 0;
flex-direction: column;
}
.records-grant-actions {
flex-wrap: wrap;
}
.records-grant-actions label {
display: inline-flex;
gap: 6px;
align-items: center;
}
@media (max-width: 1280px) {
.records-workspace {
grid-template-columns: minmax(190px, 220px) minmax(330px, 0.85fr) minmax(390px, 1fr);
@@ -623,4 +660,8 @@
.records-dialog-form > .form-field:has(textarea) {
grid-column: auto;
}
.records-grant-row {
grid-template-columns: 1fr;
}
}