fix(mail-webui): constrain focused profile updates
This commit is contained in:
@@ -42,6 +42,8 @@ import {
|
|||||||
mailProfileEditTargetShowsProfileFields,
|
mailProfileEditTargetShowsProfileFields,
|
||||||
mailProfileEditTargetShowsSettingsPanel,
|
mailProfileEditTargetShowsSettingsPanel,
|
||||||
mailProfileEditTargetVisibleSections,
|
mailProfileEditTargetVisibleSections,
|
||||||
|
mailProfileCreateCredentialsPayload,
|
||||||
|
mailProfileTargetedUpdatePayload,
|
||||||
type MailProfileEditTarget,
|
type MailProfileEditTarget,
|
||||||
type MailProfileProtocol
|
type MailProfileProtocol
|
||||||
} from "./mailProfileEditorModel";
|
} from "./mailProfileEditorModel";
|
||||||
@@ -278,7 +280,7 @@ export function MailProfileScopeManager({
|
|||||||
const created = await createMailServerProfile(settings, payload);
|
const created = await createMailServerProfile(settings, payload);
|
||||||
setSuccess(i18nMessage("i18n:govoplan-mail.profile_value_created.2a088d8d", { value0: created.name }));
|
setSuccess(i18nMessage("i18n:govoplan-mail.profile_value_created.2a088d8d", { value0: created.name }));
|
||||||
} else {
|
} else {
|
||||||
const payload = updateProfilePayload(draft, editing);
|
const payload = updateProfilePayload(draft, editing, editingTarget);
|
||||||
const updated = await updateMailServerProfile(settings, editing.id, payload);
|
const updated = await updateMailServerProfile(settings, editing.id, payload);
|
||||||
setSuccess(i18nMessage("i18n:govoplan-mail.profile_value_updated.fdbad0ea", { value0: updated.name }));
|
setSuccess(i18nMessage("i18n:govoplan-mail.profile_value_updated.fdbad0ea", { value0: updated.name }));
|
||||||
}
|
}
|
||||||
@@ -369,13 +371,16 @@ export function MailProfileScopeManager({
|
|||||||
}
|
}
|
||||||
if (row.kind === "credential") {
|
if (row.kind === "credential") {
|
||||||
return <TableActionGroup actions={[{
|
return <TableActionGroup actions={[{
|
||||||
id: "edit-credentials", label: i18nMessage("i18n:govoplan-mail.edit_value_credentials.1e2dc4f6", { value0: row.protocol.toUpperCase() }), icon: <Pencil size={16} />, 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: <Pencil size={16} />, 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 <TableActionGroup actions={[
|
return <TableActionGroup actions={[
|
||||||
{ id: "edit", label: i18nMessage("i18n:govoplan-mail.edit_value.fad75899", { value0: row.profile.name }), icon: <Pencil size={16} />, disabled: !canWriteProfiles || busy, onClick: () => openEdit(row.profile) },
|
{ id: "edit", label: i18nMessage("i18n:govoplan-mail.edit_value.fad75899", { value0: row.profile.name }), icon: <Pencil size={16} />, disabled: !canWriteProfiles || busy, onClick: () => openEdit(row.profile) },
|
||||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-mail.deactivate_value.a276a667", { value0: row.profile.name }), icon: <Trash2 size={16} />, 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: <Trash2 size={16} />, 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 {
|
function createProfilePayload(draft: ProfileDraft, scopeType: MailProfileScope, scopeId: string | null): MailServerProfilePayload {
|
||||||
|
const credentials = profileCreateCredentialsPayload(draft);
|
||||||
return {
|
return {
|
||||||
name: draft.name.trim(),
|
name: draft.name.trim(),
|
||||||
slug: mailTextOrNull(draft.slug),
|
slug: mailTextOrNull(draft.slug),
|
||||||
@@ -1101,21 +1107,27 @@ function createProfilePayload(draft: ProfileDraft, scopeType: MailProfileScope,
|
|||||||
scope_id: scopeId,
|
scope_id: scopeId,
|
||||||
smtp: smtpServerPayload(draft),
|
smtp: smtpServerPayload(draft),
|
||||||
imap: hasDraftImapSettings(draft) ? imapServerPayload(draft) : null,
|
imap: hasDraftImapSettings(draft) ? imapServerPayload(draft) : null,
|
||||||
credentials: profileCredentialsPayload(draft, false)
|
...(credentials ? { credentials } : {})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateProfilePayload(draft: ProfileDraft, profile: MailServerProfile): MailServerProfileUpdatePayload {
|
function updateProfilePayload(
|
||||||
return {
|
draft: ProfileDraft,
|
||||||
|
profile: MailServerProfile,
|
||||||
|
target: MailProfileEditTarget
|
||||||
|
): MailServerProfileUpdatePayload {
|
||||||
|
return mailProfileTargetedUpdatePayload(target, {
|
||||||
|
profile: {
|
||||||
name: draft.name.trim(),
|
name: draft.name.trim(),
|
||||||
slug: mailTextOrNull(draft.slug),
|
slug: mailTextOrNull(draft.slug),
|
||||||
description: draft.description.trim(),
|
description: draft.description.trim(),
|
||||||
is_active: draft.isActive,
|
is_active: draft.isActive
|
||||||
|
},
|
||||||
smtp: smtpServerPayload(draft),
|
smtp: smtpServerPayload(draft),
|
||||||
imap: hasDraftImapSettings(draft) ? imapServerPayload(draft) : null,
|
imap: hasDraftImapSettings(draft) ? imapServerPayload(draft) : null,
|
||||||
credentials: profileCredentialsPayload(draft, true),
|
credentials: profileCredentialsPayload(draft, true),
|
||||||
clear_imap: !hasDraftImapSettings(draft) && Boolean(profile.imap)
|
clearImap: !hasDraftImapSettings(draft) && Boolean(profile.imap)
|
||||||
};
|
}) as MailServerProfileUpdatePayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
function smtpServerPayload(draft: ProfileDraft): MailSmtpTestPayload {
|
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 {
|
function rawSmtpPayload(draft: ProfileDraft, preserveBlankPassword: boolean): MailSmtpTestPayload {
|
||||||
return { ...smtpServerPayload(draft), ...mailTransportCredentialsPayload(draft.smtpUsername, draft.smtpPassword, preserveBlankPassword) };
|
return { ...smtpServerPayload(draft), ...mailTransportCredentialsPayload(draft.smtpUsername, draft.smtpPassword, preserveBlankPassword) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,22 @@ export type MailProfileChildDescriptor = {
|
|||||||
protocol: MailProfileProtocol;
|
protocol: MailProfileProtocol;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MailProfileTransportCredentialsLike = {
|
||||||
|
username?: string | null;
|
||||||
|
password?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailProfileTargetedUpdateParts = {
|
||||||
|
profile: Record<string, unknown>;
|
||||||
|
smtp: Record<string, unknown>;
|
||||||
|
imap: Record<string, unknown> | null;
|
||||||
|
credentials: {
|
||||||
|
smtp: MailProfileTransportCredentialsLike;
|
||||||
|
imap: MailProfileTransportCredentialsLike;
|
||||||
|
};
|
||||||
|
clearImap: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export function mailProfileChildDescriptors(profile: MailProfileTreeProfileLike): MailProfileChildDescriptor[] {
|
export function mailProfileChildDescriptors(profile: MailProfileTreeProfileLike): MailProfileChildDescriptor[] {
|
||||||
const children: MailProfileChildDescriptor[] = [
|
const children: MailProfileChildDescriptor[] = [
|
||||||
{ kind: "server", id: `server:${profile.id}:smtp`, protocol: "smtp" },
|
{ kind: "server", id: `server:${profile.id}:smtp`, protocol: "smtp" },
|
||||||
@@ -58,3 +74,42 @@ export function mailProfileEditTargetShowsProfileFields(target: MailProfileEditT
|
|||||||
export function mailProfileEditTargetShowsSettingsPanel(target: MailProfileEditTarget): boolean {
|
export function mailProfileEditTargetShowsSettingsPanel(target: MailProfileEditTarget): boolean {
|
||||||
return target.kind !== "profile";
|
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<string, unknown> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import {
|
|||||||
mailProfileEditTargetPanelMode,
|
mailProfileEditTargetPanelMode,
|
||||||
mailProfileEditTargetShowsProfileFields,
|
mailProfileEditTargetShowsProfileFields,
|
||||||
mailProfileEditTargetShowsSettingsPanel,
|
mailProfileEditTargetShowsSettingsPanel,
|
||||||
mailProfileEditTargetVisibleSections
|
mailProfileEditTargetVisibleSections,
|
||||||
|
mailProfileCreateCredentialsPayload,
|
||||||
|
mailProfileTargetedUpdatePayload
|
||||||
} from "../src/features/mail/mailProfileEditorModel";
|
} from "../src/features/mail/mailProfileEditorModel";
|
||||||
|
|
||||||
const smtpOnlyChildren = mailProfileChildDescriptors({ id: "profile-1", imap: null });
|
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(mailProfileEditTargetShowsProfileFields({ kind: "server", protocol: "smtp" }), false);
|
||||||
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "profile" }), false);
|
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "profile" }), false);
|
||||||
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "create" }), true);
|
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"
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user