diff --git a/webui/src/features/mail/MailProfileManagement.tsx b/webui/src/features/mail/MailProfileManagement.tsx index 0c8f9cb..29ee5a2 100644 --- a/webui/src/features/mail/MailProfileManagement.tsx +++ b/webui/src/features/mail/MailProfileManagement.tsx @@ -42,6 +42,8 @@ import { mailProfileEditTargetShowsProfileFields, mailProfileEditTargetShowsSettingsPanel, mailProfileEditTargetVisibleSections, + mailProfileCreateCredentialsPayload, + mailProfileTargetedUpdatePayload, type MailProfileEditTarget, type MailProfileProtocol } from "./mailProfileEditorModel"; @@ -278,7 +280,7 @@ export function MailProfileScopeManager({ const created = await createMailServerProfile(settings, payload); setSuccess(i18nMessage("i18n:govoplan-mail.profile_value_created.2a088d8d", { value0: created.name })); } else { - const payload = updateProfilePayload(draft, editing); + const payload = updateProfilePayload(draft, editing, editingTarget); const updated = await updateMailServerProfile(settings, editing.id, payload); setSuccess(i18nMessage("i18n:govoplan-mail.profile_value_updated.fdbad0ea", { value0: updated.name })); } @@ -369,13 +371,16 @@ export function MailProfileScopeManager({ } if (row.kind === "credential") { return , disabled: !canWriteProfiles || busy, onClick: () => openEdit(row.profile, { kind: "credentials", protocol: row.protocol }) + id: "edit-credentials", label: i18nMessage("i18n:govoplan-mail.edit_value_credentials.1e2dc4f6", { value0: row.protocol.toUpperCase() }), icon: , disabled: !canWriteProfiles || !canManageCredentials || busy, onClick: () => openEdit(row.profile, { kind: "credentials", protocol: row.protocol }) }]} />; } + const deactivationDeletesCredentials = Boolean( + row.profile.smtp_password_configured || row.profile.imap_password_configured + ); return , disabled: !canWriteProfiles || busy, onClick: () => openEdit(row.profile) }, - { id: "deactivate", label: i18nMessage("i18n:govoplan-mail.deactivate_value.a276a667", { value0: row.profile.name }), icon: , variant: "danger", applicable: row.profile.is_active, disabled: !canWriteProfiles || busy, onClick: () => setDeactivating(row.profile) } + { id: "deactivate", label: i18nMessage("i18n:govoplan-mail.deactivate_value.a276a667", { value0: row.profile.name }), icon: , variant: "danger", applicable: row.profile.is_active, disabled: !canWriteProfiles || busy || (deactivationDeletesCredentials && !canManageCredentials), onClick: () => setDeactivating(row.profile) } ]} />; } @@ -1092,6 +1097,7 @@ function profileToDraft(profile: MailServerProfile): ProfileDraft { } function createProfilePayload(draft: ProfileDraft, scopeType: MailProfileScope, scopeId: string | null): MailServerProfilePayload { + const credentials = profileCreateCredentialsPayload(draft); return { name: draft.name.trim(), slug: mailTextOrNull(draft.slug), @@ -1101,21 +1107,27 @@ function createProfilePayload(draft: ProfileDraft, scopeType: MailProfileScope, scope_id: scopeId, smtp: smtpServerPayload(draft), imap: hasDraftImapSettings(draft) ? imapServerPayload(draft) : null, - credentials: profileCredentialsPayload(draft, false) + ...(credentials ? { credentials } : {}) }; } -function updateProfilePayload(draft: ProfileDraft, profile: MailServerProfile): MailServerProfileUpdatePayload { - return { - name: draft.name.trim(), - slug: mailTextOrNull(draft.slug), - description: draft.description.trim(), - is_active: draft.isActive, +function updateProfilePayload( + draft: ProfileDraft, + profile: MailServerProfile, + target: MailProfileEditTarget +): MailServerProfileUpdatePayload { + return mailProfileTargetedUpdatePayload(target, { + profile: { + name: draft.name.trim(), + slug: mailTextOrNull(draft.slug), + description: draft.description.trim(), + is_active: draft.isActive + }, smtp: smtpServerPayload(draft), imap: hasDraftImapSettings(draft) ? imapServerPayload(draft) : null, credentials: profileCredentialsPayload(draft, true), - clear_imap: !hasDraftImapSettings(draft) && Boolean(profile.imap) - }; + clearImap: !hasDraftImapSettings(draft) && Boolean(profile.imap) + }) as MailServerProfileUpdatePayload; } function smtpServerPayload(draft: ProfileDraft): MailSmtpTestPayload { @@ -1139,6 +1151,13 @@ function profileCredentialsPayload(draft: ProfileDraft, preserveBlankPassword: b }; } +function profileCreateCredentialsPayload(draft: ProfileDraft) { + return mailProfileCreateCredentialsPayload( + mailTransportCredentialsPayload(draft.smtpUsername, draft.smtpPassword, true), + mailTransportCredentialsPayload(draft.imapUsername, draft.imapPassword, true) + ); +} + function rawSmtpPayload(draft: ProfileDraft, preserveBlankPassword: boolean): MailSmtpTestPayload { return { ...smtpServerPayload(draft), ...mailTransportCredentialsPayload(draft.smtpUsername, draft.smtpPassword, preserveBlankPassword) }; } diff --git a/webui/src/features/mail/mailProfileEditorModel.ts b/webui/src/features/mail/mailProfileEditorModel.ts index e6480f8..5d2a9ed 100644 --- a/webui/src/features/mail/mailProfileEditorModel.ts +++ b/webui/src/features/mail/mailProfileEditorModel.ts @@ -23,6 +23,22 @@ export type MailProfileChildDescriptor = { protocol: MailProfileProtocol; }; +export type MailProfileTransportCredentialsLike = { + username?: string | null; + password?: string | null; +}; + +export type MailProfileTargetedUpdateParts = { + profile: Record; + smtp: Record; + imap: Record | null; + credentials: { + smtp: MailProfileTransportCredentialsLike; + imap: MailProfileTransportCredentialsLike; + }; + clearImap: boolean; +}; + export function mailProfileChildDescriptors(profile: MailProfileTreeProfileLike): MailProfileChildDescriptor[] { const children: MailProfileChildDescriptor[] = [ { kind: "server", id: `server:${profile.id}:smtp`, protocol: "smtp" }, @@ -58,3 +74,42 @@ export function mailProfileEditTargetShowsProfileFields(target: MailProfileEditT export function mailProfileEditTargetShowsSettingsPanel(target: MailProfileEditTarget): boolean { return target.kind !== "profile"; } + +/** + * Keep the PATCH authority surface equal to the focused editor. Profile and + * server edits must not accidentally replay credential fields merely because + * the form draft contains their displayed values. + */ +export function mailProfileTargetedUpdatePayload( + target: MailProfileEditTarget, + parts: MailProfileTargetedUpdateParts +): Record { + if (target.kind === "profile") return parts.profile; + if (target.kind === "server") { + if (target.protocol === "smtp") return { smtp: parts.smtp }; + return parts.imap === null + ? { imap: null, clear_imap: parts.clearImap } + : { imap: parts.imap }; + } + if (target.kind === "credentials") { + return { credentials: { [target.protocol]: parts.credentials[target.protocol] } }; + } + throw new Error("Create is not an update target"); +} + +/** Omit blank create credentials so profile-write remains independent. */ +export function mailProfileCreateCredentialsPayload( + smtp: MailProfileTransportCredentialsLike, + imap: MailProfileTransportCredentialsLike +): {smtp?: MailProfileTransportCredentialsLike;imap?: MailProfileTransportCredentialsLike;} | undefined { + const populated = (value: MailProfileTransportCredentialsLike): MailProfileTransportCredentialsLike => + Object.fromEntries( + Object.entries(value).filter(([, item]) => item !== null && item !== undefined && item !== "") + ); + const smtpCredentials = populated(smtp); + const imapCredentials = populated(imap); + const result: {smtp?: MailProfileTransportCredentialsLike;imap?: MailProfileTransportCredentialsLike;} = {}; + if (Object.keys(smtpCredentials).length > 0) result.smtp = smtpCredentials; + if (Object.keys(imapCredentials).length > 0) result.imap = imapCredentials; + return Object.keys(result).length > 0 ? result : undefined; +} diff --git a/webui/tests/mail-profile-editor-model.test.ts b/webui/tests/mail-profile-editor-model.test.ts index 0c80970..5517633 100644 --- a/webui/tests/mail-profile-editor-model.test.ts +++ b/webui/tests/mail-profile-editor-model.test.ts @@ -18,7 +18,9 @@ import { mailProfileEditTargetPanelMode, mailProfileEditTargetShowsProfileFields, mailProfileEditTargetShowsSettingsPanel, - mailProfileEditTargetVisibleSections + mailProfileEditTargetVisibleSections, + mailProfileCreateCredentialsPayload, + mailProfileTargetedUpdatePayload } from "../src/features/mail/mailProfileEditorModel"; const smtpOnlyChildren = mailProfileChildDescriptors({ id: "profile-1", imap: null }); @@ -46,3 +48,42 @@ assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "profile" }), true); assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "server", protocol: "smtp" }), false); assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "profile" }), false); assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "create" }), true); + +const updateParts = { + profile: { name: "Renamed" }, + smtp: { host: "smtp.example.org" }, + imap: { host: "imap.example.org" }, + credentials: { + smtp: { username: "smtp-user", password: "smtp-secret" }, + imap: { username: "imap-user", password: "imap-secret" } + }, + clearImap: false +}; +assertDeepEqual( + mailProfileTargetedUpdatePayload({ kind: "profile" }, updateParts), + { name: "Renamed" }, + "profile edits do not replay transport or credential fields" +); +assertDeepEqual( + mailProfileTargetedUpdatePayload({ kind: "server", protocol: "smtp" }, updateParts), + { smtp: { host: "smtp.example.org" } }, + "server edits do not replay credential fields" +); +assertDeepEqual( + mailProfileTargetedUpdatePayload({ kind: "credentials", protocol: "imap" }, updateParts), + { credentials: { imap: { username: "imap-user", password: "imap-secret" } } }, + "credential edits send only the selected protocol" +); +assertEqual( + mailProfileCreateCredentialsPayload({ username: null }, { password: "" }), + undefined, + "credential-free creates omit the credential object" +); +assertDeepEqual( + mailProfileCreateCredentialsPayload( + { username: "smtp-user", password: null }, + { username: null, password: "imap-secret" } + ), + { smtp: { username: "smtp-user" }, imap: { password: "imap-secret" } }, + "create payloads retain only explicitly populated credential fields" +);