Refine reusable mail profile credential editing

This commit is contained in:
2026-07-31 02:48:56 +02:00
parent 0cb6719b91
commit 2721ee6542
5 changed files with 246 additions and 117 deletions
+110 -10
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { AdminSelectionList, ConnectionTree, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, StatusBadge, TableActionGroup, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui";
import { Link2, Pencil, Plus, Trash2, Unlink } from "lucide-react";
import { AdminSelectionList, ConnectionTree, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, StageRail, StatusBadge, TableActionGroup, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui";
import { ArrowLeft, ArrowRight, Inbox, KeyRound, Link2, Pencil, Plus, Send, Settings2, Trash2, Unlink } from "lucide-react";
import type { ApiSettings } from "../../types";
import {
bindMailServerCredential,
@@ -60,7 +60,11 @@ import {
mailProfileEditTargetShowsProfileFields,
mailProfileEditTargetShowsSettingsPanel,
mailProfileEditTargetVisibleSections,
mailProfileCreateStageCanContinue,
mailProfileCreateStagePanel,
mailProfileCreateStages,
mailProfileCreateCredentialsPayload,
type MailProfileCreateStage,
type MailProfileEditTarget,
type MailProfileProtocol
} from "./mailProfileEditorModel";
@@ -179,6 +183,7 @@ export function MailProfileScopeManager({
const [selectedTargetId, setSelectedTargetId] = useState(scopeId || targetOptions[0]?.id || "");
const [editing, setEditing] = useState<EditingProfile>(null);
const [editingTarget, setEditingTarget] = useState<MailProfileEditTarget>({ kind: "create" });
const [createStage, setCreateStage] = useState<MailProfileCreateStage>("profile");
const [deactivating, setDeactivating] = useState<MailServerProfile | null>(null);
const [pendingHierarchyRemoval, setPendingHierarchyRemoval] = useState<PendingHierarchyRemoval>(null);
const [availableCredentials, setAvailableCredentials] = useState<MailCredentialEnvelope[]>([]);
@@ -204,6 +209,11 @@ export function MailProfileScopeManager({
profileDraftKey(draft) !== savedProfileDraftKey
|| reuseCredentialId !== savedReuseCredentialId
);
const createStageIndex = mailProfileCreateStages.indexOf(createStage);
const createStageCanContinue = mailProfileCreateStageCanContinue(
createStage,
draft
);
useUnsavedDraftGuard({
dirty: profileDirty,
@@ -287,6 +297,7 @@ export function MailProfileScopeManager({
setAvailableCredentials([]);
setReuseCredentialId("");
setSavedReuseCredentialId("");
setCreateStage("profile");
setEditingTarget({ kind: "create" });
setEditing("new");
setError("");
@@ -338,9 +349,15 @@ export function MailProfileScopeManager({
setAvailableCredentials([]);
setReuseCredentialId("");
setSavedReuseCredentialId("");
setCreateStage("profile");
setEditingTarget({ kind: "create" });
}
function moveCreateStage(offset: number) {
const next = mailProfileCreateStages[createStageIndex + offset];
if (next) setCreateStage(next);
}
async function saveProfile(): Promise<boolean> {
if (!editing || !scopeReady) return false;
setBusy(true);
@@ -662,8 +679,52 @@ export function MailProfileScopeManager({
onClose={() => !busy && closeProfileEditor()}
className="admin-dialog admin-dialog-wide mail-profile-dialog"
closeDisabled={busy}
footer={<><Button onClick={closeProfileEditor} disabled={busy}>i18n:govoplan-mail.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveProfile()} disabled={!canWriteProfiles || busy || !scopeReady || !profileEditorCanSave(draft, editing, editingTarget, reuseCredentialId)}>{busy ? "i18n:govoplan-mail.saving.56a2285c" : "i18n:govoplan-mail.save_profile.f597c0e8"}</Button></>}>
footer={editing === "new"
? <>
<Button onClick={closeProfileEditor} disabled={busy}>i18n:govoplan-mail.cancel.77dfd213</Button>
{createStageIndex > 0 && (
<Button onClick={() => moveCreateStage(-1)} disabled={busy}>
<ArrowLeft size={16} />
Back
</Button>
)}
{createStageIndex < mailProfileCreateStages.length - 1
? (
<Button
variant="primary"
onClick={() => moveCreateStage(1)}
disabled={!canWriteProfiles || busy || !createStageCanContinue}
>
Next
<ArrowRight size={16} />
</Button>
)
: (
<Button
variant="primary"
onClick={() => void saveProfile()}
disabled={!canWriteProfiles || busy || !scopeReady || !profileEditorCanSave(draft, editing, editingTarget, reuseCredentialId)}
>
{busy ? "i18n:govoplan-mail.saving.56a2285c" : "Create profile"}
</Button>
)}
</>
: <><Button onClick={closeProfileEditor} disabled={busy}>i18n:govoplan-mail.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveProfile()} disabled={!canWriteProfiles || busy || !scopeReady || !profileEditorCanSave(draft, editing, editingTarget, reuseCredentialId)}>{busy ? "i18n:govoplan-mail.saving.56a2285c" : "i18n:govoplan-mail.save_profile.f597c0e8"}</Button></>}>
{editing === "new" && (
<StageRail
ariaLabel="Mail profile creation"
className="mail-profile-create-rail"
items={mailProfileCreateStages.map((stage, index) => ({
id: stage,
label: mailProfileCreateStageLabel(stage),
icon: mailProfileCreateStageIcon(stage),
current: stage === createStage,
tone: index < createStageIndex ? "success" : stage === createStage ? "active" : "neutral",
connectorTone: index < createStageIndex ? "success" : "neutral"
}))}
/>
)}
<ProfileForm
settings={settings}
profiles={profiles}
@@ -675,6 +736,7 @@ export function MailProfileScopeManager({
canManageCredentials={canManageCredentials}
effectivePolicy={profileEffectivePolicy}
editTarget={editingTarget}
createStage={editing === "new" ? createStage : null}
availableCredentials={availableCredentials}
reuseCredentialId={reuseCredentialId}
setReuseCredentialId={setReuseCredentialId} />
@@ -1000,6 +1062,7 @@ function ProfileForm({
canManageCredentials,
effectivePolicy,
editTarget,
createStage,
availableCredentials,
reuseCredentialId,
setReuseCredentialId
@@ -1014,6 +1077,7 @@ function ProfileForm({
canManageCredentials: boolean;
effectivePolicy?: MailProfilePolicy | null;
editTarget: MailProfileEditTarget;
createStage: MailProfileCreateStage | null;
availableCredentials: MailCredentialEnvelope[];
reuseCredentialId: string;
setReuseCredentialId: (credentialId: string) => void;
@@ -1027,11 +1091,19 @@ function ProfileForm({
const disabled = busy || !canWrite;
const credentialDisabled = disabled || !canManageCredentials;
const existingProfile = editing !== "new" ? editing : null;
const initialSection = mailProfileEditTargetInitialSection(editTarget);
const settingsPanelMode = mailProfileEditTargetPanelMode(editTarget);
const visibleSections = mailProfileEditTargetVisibleSections(editTarget);
const showProfileFields = mailProfileEditTargetShowsProfileFields(editTarget);
const showSettingsPanel = mailProfileEditTargetShowsSettingsPanel(editTarget);
const createPanel = editTarget.kind === "create" && createStage
? mailProfileCreateStagePanel(createStage)
: null;
const initialSection = createPanel?.initialSection
?? mailProfileEditTargetInitialSection(editTarget);
const settingsPanelMode = createPanel?.panelMode
?? mailProfileEditTargetPanelMode(editTarget);
const visibleSections = createPanel?.visibleSections
?? mailProfileEditTargetVisibleSections(editTarget);
const showProfileFields = createPanel?.showProfileFields
?? mailProfileEditTargetShowsProfileFields(editTarget);
const showSettingsPanel = createPanel?.showSettingsPanel
?? mailProfileEditTargetShowsSettingsPanel(editTarget);
const draftHasImap = hasDraftImapSettings(draft);
const selectedServerId = editTarget.kind === "server" || editTarget.kind === "credentials" ? editTarget.serverId : undefined;
const selectedCredentialId = editTarget.kind === "credentials" ? editTarget.credentialId : undefined;
@@ -1289,7 +1361,11 @@ function ProfileForm({
</div>
}
{policyMessages.length > 0 &&
{policyMessages.length > 0 && (
editTarget.kind !== "create"
|| createStage === "smtp_server"
|| createStage === "imap_server"
) &&
<DismissibleAlert tone="warning" resetKey={policyMessages.map((item) => `${item.key}:${item.value}`).join("|")} dismissible={false}>
<strong>i18n:govoplan-mail.effective_mail_policy_blocks_the_current_profile.1b555820</strong>
<ul>{policyMessages.map((item) => <li key={`${item.key}:${item.value}`}>{item.message}</li>)}</ul>
@@ -1310,7 +1386,14 @@ function ProfileForm({
smtpCredentialDisabled={credentialDisabled}
smtpPasswordSaved={Boolean(selectedCredential?.secret_configured || existingProfile?.smtp_password_configured)}
imapServerDisabled={disabled}
imapCredentialDisabled={credentialDisabled}
imapCredentialDisabled={
credentialDisabled
|| (
editTarget.kind === "create"
&& createStage === "imap_credentials"
&& !draftHasImap
)
}
imapPasswordSaved={Boolean(selectedCredential?.secret_configured || existingProfile?.imap_password_configured)}
imapActionDisabled={disabled || !draftHasImap}
smtpTestLabel={useSavedSmtpTest ? "i18n:govoplan-mail.test_saved_smtp.008d8054" : "i18n:govoplan-mail.test_smtp.e5697981"}
@@ -1409,6 +1492,23 @@ function credentialAvailabilityLabel(credential: MailCredentialEnvelope): string
return `${credential.inherit_to_lower_scopes ? "Inherited" : "Current scope"} · ${modules} · ${servers}`;
}
function mailProfileCreateStageLabel(stage: MailProfileCreateStage): string {
return {
profile: "Profile",
smtp_server: "SMTP server",
smtp_credentials: "SMTP credential",
imap_server: "IMAP server",
imap_credentials: "IMAP credential"
}[stage];
}
function mailProfileCreateStageIcon(stage: MailProfileCreateStage): ReactNode {
if (stage === "profile") return <Settings2 size={17} />;
if (stage === "smtp_server") return <Send size={17} />;
if (stage === "imap_server") return <Inbox size={17} />;
return <KeyRound size={17} />;
}
function profileDialogTitle(editing: EditingProfile, target: MailProfileEditTarget): string {
if (editing === "new") return "i18n:govoplan-mail.create_mail_profile.4d2f8f9f";
if (target.kind === "server") return `${target.serverId ? "Edit" : "Add"} ${target.protocol.toUpperCase()} server`;