Complete guided Core configuration patterns

This commit is contained in:
2026-08-03 10:16:26 +02:00
parent 2d0551a845
commit fa32cca03f
8 changed files with 252 additions and 17 deletions
@@ -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));
}
+39 -4
View File
@@ -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>