Complete guided Core configuration patterns
This commit is contained in:
@@ -39,6 +39,7 @@ operator, and roadmap pages.
|
||||
| Product roadmap and module routing | `GOVOPLAN_MASTER_ROADMAP.md` | Product-level sequencing, implementation gates, issue routing, and missing-module decisions. |
|
||||
| Institutional governance target | `govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md` | Cross-product semantic layers, source-authority modes, candidate Mandates/Services/Parties/Decisions boundaries, and migration sequence. |
|
||||
| UI/UX decisions | `UI_UX_DECISION_LEDGER.md` | Binding guided-UI decisions, open decisions, impact index, and review checklist. |
|
||||
| Core interface migration | `INTERFACE_PATTERN_MIGRATION.md` | Core-owned settings, credential, retention, lifecycle, and shared-component evidence for the product pattern language. |
|
||||
| Interface ethics and design doctrine | `INTERFACE_ETHICS_AND_DESIGN_DOCTRINE.md` | Product-level doctrine for context, decision, consequence, contestability, responsibility, and traceability. |
|
||||
| Public-sector integration posture | `PUBLIC_SECTOR_INTEGRATION_STRATEGY.md` | Strategy index; executable target inventory lives in `govoplan-connectors`. |
|
||||
| Configuration packages | `CONFIGURATION_PACKAGES.md` | Package model, provider contract, import/export flow, and tracking slices. |
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Core Interface Pattern Migration
|
||||
|
||||
This document records the Core-owned part of the product-wide interface
|
||||
pattern-language rollout. The normative product grammar and complete route
|
||||
inventory live in the `govoplan` meta repository. Core owns reusable behavior;
|
||||
domain modules own their compositions.
|
||||
|
||||
## Core Surfaces
|
||||
|
||||
| Surface | Pattern | Consequence and provenance contract | Evidence |
|
||||
| --- | --- | --- | --- |
|
||||
| User settings | Two-zone settings workspace with typed controls and unsaved-change protection | Save actions distinguish busy, unchanged, and test-in-progress states; contextual help resolves through Docs or the hosted fallback | `SettingsPage.tsx`, `test-core-interface-patterns.mjs` |
|
||||
| Reusable credentials | Repeated administration with an adaptive create/edit dialog and destructive confirmation | Secret values are write-only; scope/permission blockers name the required action, responsible actor, and destination; unavailable row actions remain keyboard-explainable | `CredentialEnvelopeManager.tsx`, shared `ActionBlockerHint`, `Button`, `TableActionGroup`, and `ConfirmDialog` |
|
||||
| Retention policy | Effective-policy editor with inherited source paths and typed, narrowing-only controls | Parent locks and missing write authority are explicit; the save action distinguishes locks, missing target, loading, clean draft, and active save | `RetentionPolicyManagement.tsx`, policy logic tests, `test-core-interface-patterns.mjs` |
|
||||
| Module lifecycle | Guided operator projection over durable installer-queue evidence | Preflight, handoff, progress, stale evidence, recovery, and rollback consequences remain visible | Admin module lifecycle tests and the Core installer-queue contract |
|
||||
| Shared configuration primitives | Cross-module component contract | Dialog focus, blocker structure, disabled-action focus, contextual help, unsaved changes, confirmation, loading, alerts, problem lists, and policy provenance are centralized | Core component tests and module-permutation build |
|
||||
|
||||
## Boundary
|
||||
|
||||
Files and Mail are the first two external consumers of the layered
|
||||
server/credential/policy pattern. Their own repositories retain provider
|
||||
discovery, transport behavior, authorization, and migration evidence. Remaining
|
||||
module surfaces are tracked by bounded module-owned issues under GovOPlaN #11;
|
||||
they are not reasons to add sibling-private behavior to Core.
|
||||
|
||||
Raw JSON remains permitted only for diagnostics, expert inspection,
|
||||
interchange, or conflict evidence. It is not a primary Core configuration
|
||||
editor.
|
||||
@@ -307,14 +307,14 @@ converted or reviewed.
|
||||
|
||||
| Surface | Repository | UX State | Next Action |
|
||||
| --- | --- | --- | --- |
|
||||
| File connector settings | `govoplan-files` | First adaptive modal slice started: connections and credentials now use full-state create/edit forms with conditional fields, advanced panels, and blocker primitives. Wizard shell is retained for later assisted setup. Central policy card still needs a layered editor. | Finish provider discovery/test-in-flow, then convert policy editing. |
|
||||
| Mail server settings | `govoplan-mail` / `govoplan-core` | Uses the shared server/credential model visually, but create/edit still needs the same adaptive pattern as files. | Migrate to adaptive server/credential/policy dialogs, with optional assisted wizard later. |
|
||||
| File connector settings | `govoplan-files` | Migrated to the shared adaptive server/credential/policy pattern with provider discovery, typed controls, actionable blockers, consequence-aware removal, and module-owned verification evidence. | Continue only through bounded Files-owned follow-ups. |
|
||||
| Mail server settings | `govoplan-mail` / `govoplan-core` | Migrated to the same layered profile/server/credential/policy pattern, including focused connection tests, unsaved-state handling, contextual help, and permission/target blockers. | Continue only through bounded Mail-owned follow-ups. |
|
||||
| Connector policy/effective rows | `govoplan-core`, module UIs | Effective-policy direction exists, but many editors still expose broad option sets. | Put effective value first, move overrides into modal, and explain blocked edits. |
|
||||
| Admin module management | `govoplan-admin` | Has preflight concepts, but operational choices are still technical and dense. | Convert install/uninstall/package changes to operator wizards. |
|
||||
| Configuration packages | `govoplan-admin` | Catalog/import work exists, but package editing can still drift toward technical fields. | Add guided import/review/problem-list flow. |
|
||||
| Retention and privacy | `govoplan-core` | Functional editor exists; consequence language and provenance can be stronger. | Layer advanced retention options and add review for broad changes. |
|
||||
| Retention and privacy | `govoplan-core` | Typed effective-policy editor exposes source paths, narrowing semantics, platform locks, permission/target blockers, and explicit clean/loading/save states. | Broader governed-change review remains module-owned where a policy change requires approval. |
|
||||
| API keys | `govoplan-access` / admin UI | Security-sensitive creation needs least-privilege guidance. | Add scoped creation wizard with expiry/owner review. |
|
||||
| User settings | `govoplan-core` | Preferences persistence exists; interface navigation issue was fixed earlier, but the surface still needs UX review. | Keep simple sections, remove double-click traps, and add quiet explanations. |
|
||||
| User settings | `govoplan-core` | Simple typed sections use unsaved-change guards, quiet result feedback, contextual help, and explicit busy/clean disabled-action reasons. | Keep bounded; new contributed sections must satisfy the checklist. |
|
||||
|
||||
## Impact Index
|
||||
|
||||
|
||||
+2
-1
@@ -26,13 +26,14 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 5173",
|
||||
"prebuild": "npm run audit:i18n-structural && npm run test:theme-contract",
|
||||
"prebuild": "npm run audit:i18n-structural && npm run test:theme-contract && npm run test:core-interface-patterns",
|
||||
"build": "tsc && vite build && node scripts/check-bundle-budget.mjs",
|
||||
"check:bundle-budget": "node scripts/check-bundle-budget.mjs",
|
||||
"preview": "vite preview --host 127.0.0.1 --port 4173",
|
||||
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
|
||||
"test:i18n-catalog": "node --test tests/i18n-catalog-validation.test.mjs",
|
||||
"test:theme-contract": "node scripts/test-theme-contract.mjs",
|
||||
"test:core-interface-patterns": "node scripts/test-core-interface-patterns.mjs",
|
||||
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
|
||||
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js && node .component-test-build/tests/data-grid-sizing.test.js",
|
||||
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const webuiRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const read = (path) => readFileSync(resolve(webuiRoot, path), "utf8");
|
||||
|
||||
const settings = read("src/features/settings/SettingsPage.tsx");
|
||||
const retention = read("src/features/privacy/RetentionPolicyManagement.tsx");
|
||||
const credentials = read("src/components/CredentialEnvelopeManager.tsx");
|
||||
|
||||
assert.match(settings, /contextId: "core\.settings"/, "settings expose stable contextual documentation");
|
||||
assert.match(settings, /There are no unsaved profile changes\./, "profile save explains its clean state");
|
||||
assert.match(settings, /There are no unsaved interface changes\./, "preference save explains its clean state");
|
||||
|
||||
assert.match(retention, /<ActionBlockerHint/, "retention renders the shared actionable blocker");
|
||||
assert.match(retention, /contextId: "privacy\.retention"/, "retention exposes stable admin documentation");
|
||||
assert.match(retention, /locked by platform configuration/, "retention explains platform locks");
|
||||
assert.doesNotMatch(retention, /<textarea/, "retention does not use raw text or JSON as its primary editor");
|
||||
|
||||
assert.match(credentials, /<ActionBlockerHint/, "credentials render the shared actionable blocker");
|
||||
assert.match(credentials, /contextId: "access\.credentials"/, "credentials expose stable admin documentation");
|
||||
assert.match(credentials, /disabledReason: writeDisabledReason/, "credential row actions retain actionable disabled reasons");
|
||||
assert.doesNotMatch(credentials, /<textarea/, "credentials use typed controls rather than a primary JSON editor");
|
||||
|
||||
console.log("Core interface-pattern contracts passed.");
|
||||
@@ -40,6 +40,8 @@ import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import type {
|
||||
CredentialReferenceSelectorsUiCapability
|
||||
} from "./ReferenceSelect";
|
||||
import ActionBlockerHint, { type ActionBlockerReason } from "./ActionBlockerHint";
|
||||
import DocumentationHelpLink from "./help/DocumentationHelpLink";
|
||||
|
||||
export type CredentialEnvelopeTargetOption = {
|
||||
id: string;
|
||||
@@ -95,6 +97,11 @@ const EMPTY_DRAFT: CredentialDraft = {
|
||||
retainedPublicData: {}
|
||||
};
|
||||
|
||||
const CREDENTIAL_DOCUMENTATION = {
|
||||
contextId: "access.credentials",
|
||||
documentationType: "admin" as const
|
||||
};
|
||||
|
||||
export default function CredentialEnvelopeManager({
|
||||
settings,
|
||||
scopeType,
|
||||
@@ -377,6 +384,27 @@ export default function CredentialEnvelopeManager({
|
||||
const saveDisabled = saving || !canWrite || !draft.name.trim() ||
|
||||
(editing === "new" && !draft.secret.trim()) ||
|
||||
(kindChanged && !draft.secret.trim());
|
||||
const managerBlocker = credentialManagerBlocker({ canWrite, scopeReady, scopeType });
|
||||
const writeDisabledReason = saving
|
||||
? "A credential change is already being saved."
|
||||
: !scopeReady
|
||||
? "Select a credential owner before changing credentials."
|
||||
: !canWrite
|
||||
? "Your account may inspect credentials at this scope but cannot change them."
|
||||
: undefined;
|
||||
const saveDisabledReason = saving
|
||||
? "The credential is already being saved."
|
||||
: !canWrite
|
||||
? "Your account may inspect this credential but cannot change it."
|
||||
: !scopeReady
|
||||
? "Select a credential owner before saving."
|
||||
: !draft.name.trim()
|
||||
? "Enter a credential name before saving."
|
||||
: editing === "new" && !draft.secret.trim()
|
||||
? "Enter a secret before creating the credential."
|
||||
: kindChanged && !draft.secret.trim()
|
||||
? "Enter a replacement secret when changing the credential type."
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="credential-envelope-manager">
|
||||
@@ -397,16 +425,24 @@ export default function CredentialEnvelopeManager({
|
||||
)}
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{notice && !error && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
|
||||
{managerBlocker && (
|
||||
<ActionBlockerHint
|
||||
reason={managerBlocker}
|
||||
documentation={CREDENTIAL_DOCUMENTATION}
|
||||
/>
|
||||
)}
|
||||
<Card
|
||||
title={title}
|
||||
actions={
|
||||
<div className="button-row compact-actions">
|
||||
<DocumentationHelpLink reference={CREDENTIAL_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />
|
||||
<Button
|
||||
type="button"
|
||||
title="Reload credentials"
|
||||
aria-label="Reload credentials"
|
||||
onClick={() => void loadCredentials()}
|
||||
disabled={loading}
|
||||
disabled={loading || !scopeReady}
|
||||
disabledReason={loading ? "Credentials are already loading." : !scopeReady ? "Select a credential owner before reloading." : undefined}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</Button>
|
||||
@@ -414,7 +450,8 @@ export default function CredentialEnvelopeManager({
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={openCreate}
|
||||
disabled={!canWrite || !scopeReady || loading}
|
||||
disabled={Boolean(writeDisabledReason) || loading}
|
||||
disabledReason={loading ? "Wait until credentials have loaded." : writeDisabledReason}
|
||||
>
|
||||
<Plus size={16} /> Add credential
|
||||
</Button>
|
||||
@@ -435,7 +472,8 @@ export default function CredentialEnvelopeManager({
|
||||
label: `Edit ${credential.name}`,
|
||||
icon: <Pencil size={15} />,
|
||||
onClick: () => openEdit(credential),
|
||||
disabled: !canWrite || saving
|
||||
disabled: !canWrite || saving,
|
||||
disabledReason: writeDisabledReason
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
@@ -443,6 +481,7 @@ export default function CredentialEnvelopeManager({
|
||||
icon: <Trash2 size={15} />,
|
||||
onClick: () => setDeleting(credential),
|
||||
disabled: !canWrite || saving,
|
||||
disabledReason: writeDisabledReason,
|
||||
variant: "danger"
|
||||
}
|
||||
]}
|
||||
@@ -461,8 +500,8 @@ export default function CredentialEnvelopeManager({
|
||||
footerClassName="button-row compact-actions"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={closeEditor} disabled={saving}>Cancel</Button>
|
||||
<Button variant="primary" onClick={() => void saveDraft()} disabled={saveDisabled}>
|
||||
<Button onClick={closeEditor} disabled={saving} disabledReason={saving ? "Wait for the credential save to finish." : undefined}>Cancel</Button>
|
||||
<Button variant="primary" onClick={() => void saveDraft()} disabled={saveDisabled} disabledReason={saveDisabledReason}>
|
||||
<KeyRound size={16} /> {saving ? "Saving" : "Save credential"}
|
||||
</Button>
|
||||
</>
|
||||
@@ -555,6 +594,35 @@ export default function CredentialEnvelopeManager({
|
||||
);
|
||||
}
|
||||
|
||||
function credentialManagerBlocker({
|
||||
canWrite,
|
||||
scopeReady,
|
||||
scopeType
|
||||
}: {
|
||||
canWrite: boolean;
|
||||
scopeReady: boolean;
|
||||
scopeType: CredentialEnvelopeManagerProps["scopeType"];
|
||||
}): ActionBlockerReason | null {
|
||||
if (!scopeReady) {
|
||||
return {
|
||||
summary: "A credential owner is required.",
|
||||
details: `No ${scopeType} target is currently selected.`,
|
||||
requiredAction: "Select the user or group that will own the reusable credential.",
|
||||
actor: "An administrator with access to this settings surface",
|
||||
target: "The target selector above"
|
||||
};
|
||||
}
|
||||
if (!canWrite) {
|
||||
return {
|
||||
summary: "You can inspect reusable credentials but cannot change them.",
|
||||
requiredAction: "Ask for credential-management authority if a change is required.",
|
||||
actor: "A scope or access administrator",
|
||||
target: "Access and role administration"
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function credentialDraft(credential: CredentialEnvelopeSummary): CredentialDraft {
|
||||
return {
|
||||
...EMPTY_DRAFT,
|
||||
|
||||
@@ -21,6 +21,8 @@ import type { PolicySourcePathItem } from "../../components/PolicySourcePath";
|
||||
import FormField from "../../components/FormField";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import { useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard";
|
||||
import ActionBlockerHint, { type ActionBlockerReason } from "../../components/ActionBlockerHint";
|
||||
import DocumentationHelpLink from "../../components/help/DocumentationHelpLink";
|
||||
import {
|
||||
privacyRetentionAuditDetailOptionDisabled,
|
||||
privacyRetentionLocalAllowsLowerLevel,
|
||||
@@ -72,6 +74,11 @@ type FieldDefinition = {
|
||||
type RawJsonValue = "inherit" | "keep" | "disable";
|
||||
type AuditDetailValue = "inherit" | PrivacyRetentionPolicy["audit_detail_level"];
|
||||
|
||||
const RETENTION_DOCUMENTATION = {
|
||||
contextId: "privacy.retention",
|
||||
documentationType: "admin" as const
|
||||
};
|
||||
|
||||
const defaultAllowLowerLevelLimits: PrivacyRetentionLimitPermissions = {
|
||||
store_raw_campaign_json: true,
|
||||
raw_campaign_json_retention_days: true,
|
||||
@@ -139,7 +146,10 @@ export function RetentionPolicyScopeManager({
|
||||
return (
|
||||
<div className="retention-policy-manager">
|
||||
{targetSelectionRequired &&
|
||||
<Card title={i18nMessage("i18n:govoplan-core.value_scope", { value0: targetLabel })}>
|
||||
<Card
|
||||
title={i18nMessage("i18n:govoplan-core.value_scope", { value0: targetLabel })}
|
||||
actions={<DocumentationHelpLink reference={RETENTION_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />}
|
||||
>
|
||||
<div className="retention-policy-target-row">
|
||||
<FormField label={targetLabel}>
|
||||
<select value={selectedTargetId} disabled={!hasSelectableTarget} onChange={(event) => setSelectedTargetId(event.target.value)}>
|
||||
@@ -201,6 +211,25 @@ export function RetentionPolicyEditor({
|
||||
const activeFullPolicy = normalizeFullPolicy({ ...defaultPrivacyPolicy, ...policy, allow_lower_level_limits: { ...defaultAllowLowerLevelLimits, ...(policy.allow_lower_level_limits ?? {}) } });
|
||||
const visibleFields = useMemo(() => fieldDefinitions.filter((field) => isSystem || !field.systemOnly), [isSystem]);
|
||||
const policyDirty = scopeReady && Boolean(savedPolicyKey) && policyDraftKey(policy, isSystem) !== savedPolicyKey;
|
||||
const blocker = retentionPolicyBlocker({ locked, canWrite, scopeReady, scopeType });
|
||||
const reloadDisabledReason = loading
|
||||
? "The retention policy is already loading."
|
||||
: !scopeReady
|
||||
? "Select a policy target before reloading."
|
||||
: undefined;
|
||||
const saveDisabledReason = busy
|
||||
? "The retention policy is already being saved."
|
||||
: loading
|
||||
? "Wait until the retention policy has loaded."
|
||||
: locked
|
||||
? "This policy is locked by platform configuration."
|
||||
: !canWrite
|
||||
? "Your account may inspect this policy but cannot change it."
|
||||
: !scopeReady
|
||||
? "Select a policy target before saving."
|
||||
: !policyDirty
|
||||
? "There are no unsaved retention-policy changes."
|
||||
: undefined;
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: policyDirty,
|
||||
@@ -315,8 +344,9 @@ export function RetentionPolicyEditor({
|
||||
title={title}
|
||||
actions={
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void loadPolicy()} disabled={loading || !scopeReady}>{loading ? "i18n:govoplan-core.loading.33ce4174" : "i18n:govoplan-core.reload.cce71553"}</Button>
|
||||
<Button variant="primary" onClick={() => void savePolicy()} disabled={disabled}>{busy ? "i18n:govoplan-core.saving.56a2285c" : "i18n:govoplan-core.save_policy.77d67ce3"}</Button>
|
||||
<DocumentationHelpLink reference={RETENTION_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />
|
||||
<Button onClick={() => void loadPolicy()} disabled={Boolean(reloadDisabledReason)} disabledReason={reloadDisabledReason}>{loading ? "i18n:govoplan-core.loading.33ce4174" : "i18n:govoplan-core.reload.cce71553"}</Button>
|
||||
<Button variant="primary" onClick={() => void savePolicy()} disabled={Boolean(saveDisabledReason)} disabledReason={saveDisabledReason}>{busy ? "i18n:govoplan-core.saving.56a2285c" : "i18n:govoplan-core.save_policy.77d67ce3"}</Button>
|
||||
</div>
|
||||
}>
|
||||
|
||||
@@ -326,6 +356,12 @@ export function RetentionPolicyEditor({
|
||||
{!isSystem && <p className="muted small-note retention-policy-description">i18n:govoplan-core.mock_mailbox_retention_is_currently_managed_at_s.8c6a8ec7</p>}
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||
{blocker && (
|
||||
<ActionBlockerHint
|
||||
reason={blocker}
|
||||
documentation={RETENTION_DOCUMENTATION}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PolicySection
|
||||
className="retention-policy-section"
|
||||
@@ -379,6 +415,45 @@ export function RetentionPolicyEditor({
|
||||
|
||||
}
|
||||
|
||||
function retentionPolicyBlocker({
|
||||
locked,
|
||||
canWrite,
|
||||
scopeReady,
|
||||
scopeType
|
||||
}: {
|
||||
locked: boolean;
|
||||
canWrite: boolean;
|
||||
scopeReady: boolean;
|
||||
scopeType: PrivacyRetentionPolicyScope;
|
||||
}): ActionBlockerReason | null {
|
||||
if (!scopeReady) {
|
||||
return {
|
||||
summary: "A retention-policy target is required.",
|
||||
details: `No ${scopeType} target is currently selected.`,
|
||||
requiredAction: "Select a target to load and edit its effective policy.",
|
||||
actor: "An administrator with access to this settings surface",
|
||||
target: "The target selector above"
|
||||
};
|
||||
}
|
||||
if (locked) {
|
||||
return {
|
||||
summary: "This retention policy is locked by platform configuration.",
|
||||
requiredAction: "Change or remove the platform lock before editing this scope.",
|
||||
actor: "A system operator",
|
||||
target: "Deployment policy configuration"
|
||||
};
|
||||
}
|
||||
if (!canWrite) {
|
||||
return {
|
||||
summary: "You can inspect this retention policy but cannot change it.",
|
||||
requiredAction: "Ask for retention-policy write authority if a change is required.",
|
||||
actor: "A scope administrator",
|
||||
target: "Access and role administration"
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function policyDraftKey(policy: PrivacyRetentionPolicyPatch, isSystem: boolean): string {
|
||||
return JSON.stringify(isSystem ? normalizeFullPolicy(policy) : normalizePatch(policy));
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { isViewSurfaceVisible } from "../../platform/views";
|
||||
import { hasAnyScope, hasScope } from "../../utils/permissions";
|
||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
import CredentialEnvelopeManager from "../../components/CredentialEnvelopeManager";
|
||||
import DocumentationHelpLink from "../../components/help/DocumentationHelpLink";
|
||||
|
||||
type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | string;
|
||||
|
||||
@@ -36,6 +37,11 @@ const UI_THEME_OPTIONS: Array<{ value: UserUiTheme; label: string }> = [
|
||||
{ value: "dark", label: "i18n:govoplan-core.dark_theme.164a90d9" }
|
||||
];
|
||||
|
||||
const SETTINGS_DOCUMENTATION = {
|
||||
contextId: "core.settings",
|
||||
documentationType: "user" as const
|
||||
};
|
||||
|
||||
function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean, canUseCredentials: boolean, contributedSections: SettingsSectionContribution[]): ModuleSubnavGroup<SettingsSection>[] {
|
||||
const groups: ModuleSubnavGroup<SettingsSection>[] = [
|
||||
{
|
||||
@@ -307,6 +313,7 @@ export default function SettingsPage({
|
||||
<PageTitle>i18n:govoplan-core.settings.c7f73bb5</PageTitle>
|
||||
<p>i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56</p>
|
||||
</div>
|
||||
<DocumentationHelpLink reference={SETTINGS_DOCUMENTATION} />
|
||||
</div>
|
||||
|
||||
{active === "profile" &&
|
||||
@@ -323,7 +330,14 @@ export default function SettingsPage({
|
||||
<input value={auth.user.email} disabled />
|
||||
</FormField>
|
||||
<div className="button-row compact-actions">
|
||||
<Button variant="primary" onClick={() => void saveProfile()} disabled={profileBusy}>{profileBusy ? "i18n:govoplan-core.saving.56a2285c" : "i18n:govoplan-core.save_profile.f597c0e8"}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void saveProfile()}
|
||||
disabled={profileBusy || !profileDirty}
|
||||
disabledReason={profileBusy ? "Profile changes are already being saved." : !profileDirty ? "There are no unsaved profile changes." : undefined}
|
||||
>
|
||||
{profileBusy ? "i18n:govoplan-core.saving.56a2285c" : "i18n:govoplan-core.save_profile.f597c0e8"}
|
||||
</Button>
|
||||
</div>
|
||||
{profileResult && <DismissibleAlert tone={profileResultTone} resetKey={profileResult} floating>{profileResult}</DismissibleAlert>}
|
||||
</div>
|
||||
@@ -401,7 +415,14 @@ export default function SettingsPage({
|
||||
onChange={setReduceMotion} />
|
||||
|
||||
<div className="button-row compact-actions">
|
||||
<Button variant="primary" onClick={() => void saveUiPreferences()} disabled={uiBusy || !uiDirty}>{uiBusy ? "i18n:govoplan-core.saving.56a2285c" : "i18n:govoplan-core.save_preferences.0f1a7e44"}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void saveUiPreferences()}
|
||||
disabled={uiBusy || !uiDirty}
|
||||
disabledReason={uiBusy ? "Interface preferences are already being saved." : !uiDirty ? "There are no unsaved interface changes." : undefined}
|
||||
>
|
||||
{uiBusy ? "i18n:govoplan-core.saving.56a2285c" : "i18n:govoplan-core.save_preferences.0f1a7e44"}
|
||||
</Button>
|
||||
</div>
|
||||
{uiResult && <DismissibleAlert tone={uiResultTone} resetKey={uiResult} floating>{uiResult}</DismissibleAlert>}
|
||||
</div>
|
||||
@@ -457,7 +478,14 @@ export default function SettingsPage({
|
||||
onChange={() => undefined} />
|
||||
|
||||
<div className="button-row compact-actions">
|
||||
<Button variant="primary" onClick={() => void saveUiPreferences()} disabled={uiBusy || !uiDirty}>{uiBusy ? "i18n:govoplan-core.saving.56a2285c" : "i18n:govoplan-core.save_preferences.0f1a7e44"}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void saveUiPreferences()}
|
||||
disabled={uiBusy || !uiDirty}
|
||||
disabledReason={uiBusy ? "Workspace preferences are already being saved." : !uiDirty ? "There are no unsaved workspace changes." : undefined}
|
||||
>
|
||||
{uiBusy ? "i18n:govoplan-core.saving.56a2285c" : "i18n:govoplan-core.save_preferences.0f1a7e44"}
|
||||
</Button>
|
||||
</div>
|
||||
{uiResult && <DismissibleAlert tone={uiResultTone} resetKey={uiResult} floating>{uiResult}</DismissibleAlert>}
|
||||
</div>
|
||||
@@ -488,7 +516,14 @@ export default function SettingsPage({
|
||||
|
||||
</FormField>
|
||||
<div className="button-row compact-actions">
|
||||
<Button variant="primary" onClick={testConnection} disabled={testing}>{testing ? "i18n:govoplan-core.testing.95c4564a" : "i18n:govoplan-core.test_connection.ccf66f07"}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={testConnection}
|
||||
disabled={testing}
|
||||
disabledReason={testing ? "The backend connection test is already running." : undefined}
|
||||
>
|
||||
{testing ? "i18n:govoplan-core.testing.95c4564a" : "i18n:govoplan-core.test_connection.ccf66f07"}
|
||||
</Button>
|
||||
</div>
|
||||
{testResult && <DismissibleAlert tone={testResultTone} resetKey={testResult} floating>{testResult}</DismissibleAlert>}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user