intermittent commit

This commit is contained in:
2026-07-14 13:22:11 +02:00
parent 4865de5007
commit b0337b2d26
28 changed files with 2264 additions and 490 deletions

View File

@@ -37,6 +37,16 @@ import { ConfirmDialog } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { FormField, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
import {
mailProfileChildDescriptors,
mailProfileEditTargetInitialSection,
mailProfileEditTargetPanelMode,
mailProfileEditTargetShowsProfileFields,
mailProfileEditTargetShowsSettingsPanel,
mailProfileEditTargetVisibleSections,
type MailProfileEditTarget,
type MailProfileProtocol
} from "./mailProfileEditorModel";
export type MailProfileTargetOption = {
id: string;
label: string;
@@ -96,7 +106,8 @@ type PolicyFlagValue = "inherit" | "allow" | "deny";
type CredentialInheritanceValue = "inherit" | "profile" | "local";
type MailProfileTreeRow =
{kind: "profile";id: string;profile: MailServerProfile;} |
{kind: "credential";id: string;profile: MailServerProfile;protocol: "smtp" | "imap";};
{kind: "server";id: string;profile: MailServerProfile;protocol: MailProfileProtocol;} |
{kind: "credential";id: string;profile: MailServerProfile;protocol: MailProfileProtocol;};
const securityOptions = mailServerSecurityOptions as readonly MailSecurity[];
@@ -135,6 +146,7 @@ export function MailProfileScopeManager({
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
const [selectedTargetId, setSelectedTargetId] = useState(scopeId || targetOptions[0]?.id || "");
const [editing, setEditing] = useState<EditingProfile>(null);
const [editingTarget, setEditingTarget] = useState<MailProfileEditTarget>({ kind: "create" });
const [deactivating, setDeactivating] = useState<MailServerProfile | null>(null);
const [draft, setDraft] = useState<ProfileDraft>(emptyProfileDraft());
const [savedProfileDraftKey, setSavedProfileDraftKey] = useState(profileDraftKey(emptyProfileDraft()));
@@ -233,15 +245,17 @@ export function MailProfileScopeManager({
const nextDraft = emptyProfileDraft();
setDraft(nextDraft);
setSavedProfileDraftKey(profileDraftKey(nextDraft));
setEditingTarget({ kind: "create" });
setEditing("new");
setError("");
setSuccess("");
}
function openEdit(profile: MailServerProfile) {
function openEdit(profile: MailServerProfile, target: MailProfileEditTarget = { kind: "profile" }) {
const nextDraft = profileToDraft(profile);
setDraft(nextDraft);
setSavedProfileDraftKey(profileDraftKey(nextDraft));
setEditingTarget(target);
setEditing(profile);
setError("");
setSuccess("");
@@ -252,6 +266,7 @@ export function MailProfileScopeManager({
const nextDraft = emptyProfileDraft();
setDraft(nextDraft);
setSavedProfileDraftKey(profileDraftKey(nextDraft));
setEditingTarget({ kind: "create" });
}
async function saveProfile(): Promise<boolean> {
@@ -308,6 +323,8 @@ export function MailProfileScopeManager({
width: "minmax(260px, 1.4fr)",
render: (row) => row.kind === "profile" ?
<div className="connection-tree-main"><strong>{row.profile.name}</strong><div className="connection-tree-muted-line">{row.profile.slug} · {scopeLabel(row.profile)}</div></div> :
row.kind === "server" ?
<MailServerTreeCell profile={row.profile} protocol={row.protocol} /> :
<MailCredentialTreeCell profile={row.profile} protocol={row.protocol} />
},
{
@@ -322,7 +339,7 @@ export function MailProfileScopeManager({
id: "policy",
header: "i18n:govoplan-mail.policy.bb9cf141",
width: "minmax(150px, 0.8fr)",
render: (row) => row.kind === "profile" ? scopeLabel(row.profile) : mailCredentialPolicySummary(profileEffectivePolicy, row.protocol)
render: (row) => row.kind === "profile" ? scopeLabel(row.profile) : row.kind === "server" ? row.protocol.toUpperCase() : mailCredentialPolicySummary(profileEffectivePolicy, row.protocol)
},
{
id: "status",
@@ -330,22 +347,33 @@ export function MailProfileScopeManager({
width: "110px",
render: (row) => row.kind === "profile" ?
<span className={`status-badge ${row.profile.is_active ? "success" : "neutral"}`}>{row.profile.is_active ? "i18n:govoplan-mail.active.a733b809" : "i18n:govoplan-mail.inactive.09af574c"}</span> :
row.kind === "server" ?
<span className={`status-badge ${transportConfigured(row.profile, row.protocol) ? "success" : "neutral"}`}>{transportConfigured(row.profile, row.protocol) ? "i18n:govoplan-core.configured.668c5fff" : "i18n:govoplan-core.not_configured.811931bb"}</span> :
<span className={`status-badge ${mailCredentialConfigured(row.profile, row.protocol) ? "success" : "neutral"}`}>{mailCredentialConfigured(row.profile, row.protocol) ? "i18n:govoplan-mail.saved.c0ae8f6e" : "i18n:govoplan-mail.local.dc99d54d"}</span>
}],
[profileEffectivePolicy]);
function mailProfileChildren(row: MailProfileTreeRow): MailProfileTreeRow[] {
if (row.kind !== "profile") return [];
const children: MailProfileTreeRow[] = [{ kind: "credential", id: `credential:${row.profile.id}:smtp`, profile: row.profile, protocol: "smtp" }];
if (row.profile.imap) children.push({ kind: "credential", id: `credential:${row.profile.id}:imap`, profile: row.profile, protocol: "imap" });
const children: MailProfileTreeRow[] = [
...mailProfileChildDescriptors(row.profile).map((child) => ({ ...child, profile: row.profile }))
];
return children;
}
function renderMailProfileActions(row: MailProfileTreeRow) {
if (row.kind === "server") {
const label = `Edit ${row.protocol.toUpperCase()} server`;
return (
<div className="admin-icon-actions">
<Button type="button" className="admin-icon-button" title={label} aria-label={label} onClick={() => openEdit(row.profile, { kind: "server", protocol: row.protocol })} disabled={!canWriteProfiles || busy}><Pencil size={16} /></Button>
</div>);
}
if (row.kind === "credential") {
return (
<div className="admin-icon-actions">
<Button type="button" className="admin-icon-button" title={i18nMessage("i18n:govoplan-mail.edit_value_credentials.1e2dc4f6", { value0: row.protocol.toUpperCase() })} aria-label={i18nMessage("i18n:govoplan-mail.edit_value_credentials.1e2dc4f6", { value0: row.protocol.toUpperCase() })} onClick={() => openEdit(row.profile)} disabled={!canWriteProfiles || busy}><Pencil size={16} /></Button>
<Button type="button" className="admin-icon-button" title={i18nMessage("i18n:govoplan-mail.edit_value_credentials.1e2dc4f6", { value0: row.protocol.toUpperCase() })} aria-label={i18nMessage("i18n:govoplan-mail.edit_value_credentials.1e2dc4f6", { value0: row.protocol.toUpperCase() })} onClick={() => openEdit(row.profile, { kind: "credentials", protocol: row.protocol })} disabled={!canWriteProfiles || busy}><Pencil size={16} /></Button>
</div>);
}
@@ -411,13 +439,13 @@ export function MailProfileScopeManager({
<Dialog
open={editing !== null}
title={editing === "new" ? "i18n:govoplan-mail.create_mail_profile.4d2f8f9f" : "i18n:govoplan-mail.edit_mail_profile.95d1af9c"}
onClose={() => !busy && setEditing(null)}
title={profileDialogTitle(editing, editingTarget)}
onClose={() => !busy && closeProfileEditor()}
className="admin-dialog admin-dialog-wide mail-profile-dialog"
closeDisabled={busy}
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-mail.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveProfile()} disabled={!canWriteProfiles || busy || !draft.name.trim() || !scopeReady}>{busy ? "i18n:govoplan-mail.saving.56a2285c" : "i18n:govoplan-mail.save_profile.f597c0e8"}</Button></>}>
footer={<><Button onClick={closeProfileEditor} disabled={busy}>i18n:govoplan-mail.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveProfile()} disabled={!canWriteProfiles || busy || !draft.name.trim() || !scopeReady}>{busy ? "i18n:govoplan-mail.saving.56a2285c" : "i18n:govoplan-mail.save_profile.f597c0e8"}</Button></>}>
<ProfileForm settings={settings} draft={draft} setDraft={setDraft} editing={editing} busy={busy} canWrite={canWriteProfiles} canManageCredentials={canManageCredentials} effectivePolicy={profileEffectivePolicy} />
<ProfileForm settings={settings} draft={draft} setDraft={setDraft} editing={editing} busy={busy} canWrite={canWriteProfiles} canManageCredentials={canManageCredentials} effectivePolicy={profileEffectivePolicy} editTarget={editingTarget} />
</Dialog>
<ConfirmDialog
@@ -771,17 +799,9 @@ function ProfileForm({
busy,
canWrite,
canManageCredentials,
effectivePolicy
}: {settings: ApiSettings;draft: ProfileDraft;setDraft: (draft: ProfileDraft) => void;editing: EditingProfile;busy: boolean;canWrite: boolean;canManageCredentials: boolean;effectivePolicy?: MailProfilePolicy | null;}) {
effectivePolicy,
editTarget
}: {settings: ApiSettings;draft: ProfileDraft;setDraft: (draft: ProfileDraft) => void;editing: EditingProfile;busy: boolean;canWrite: boolean;canManageCredentials: boolean;effectivePolicy?: MailProfilePolicy | null;editTarget: MailProfileEditTarget;}) {
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
const [folderResult, setFolderResult] = useState<MailServerFolderLookupResult | null>(null);
@@ -790,6 +810,11 @@ 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 draftHasImap = hasDraftImapSettings(draft);
const useSavedSmtpTest = Boolean(existingProfile && !draft.smtpPassword && existingProfile.smtp_password_configured);
const useSavedImapTest = Boolean(existingProfile && !draft.imapPassword && existingProfile.imap_password_configured);
@@ -803,7 +828,7 @@ function ProfileForm({
setImapTestResult(null);
setFolderResult(null);
setMailActionState(null);
}, [editing]);
}, [editing, editTarget]);
function patchSmtpSettings(patch: Partial<MailServerSmtpSettings>) {
setDraft({
@@ -896,12 +921,18 @@ function ProfileForm({
return (
<div className="mail-profile-form">
{showProfileFields &&
<div className="admin-form-grid two-columns">
<FormField label="i18n:govoplan-mail.name.709a2322"><input value={draft.name} disabled={disabled} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-mail.slug.094da9b9"><input value={draft.slug} disabled={disabled} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} placeholder="i18n:govoplan-mail.generated_from_name.33d69a91" /></FormField>
<FormField label="i18n:govoplan-mail.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={disabled} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-mail.active.a733b809</option><option value="inactive">i18n:govoplan-mail.inactive.09af574c</option></select></FormField>
<FormField label="i18n:govoplan-mail.description.55f8ebc8"><textarea rows={3} value={draft.description} disabled={disabled} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
</div>
}
{editTarget.kind === "profile" && existingProfile &&
<ProfileTransportSummary profile={existingProfile} />
}
{policyMessages.length > 0 &&
<DismissibleAlert tone="warning" resetKey={policyMessages.map((item) => `${item.key}:${item.value}`).join("|")} dismissible={false}>
@@ -910,6 +941,7 @@ function ProfileForm({
</DismissibleAlert>
}
{showSettingsPanel && settingsPanelMode &&
<MailServerSettingsPanel
smtp={{ host: draft.smtpHost, port: draft.smtpPort, security: draft.smtpSecurity, timeout_seconds: draft.smtpTimeout }}
imap={{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, timeout_seconds: draft.imapTimeout }}
@@ -936,7 +968,11 @@ function ProfileForm({
imapTestResult={imapTestResult}
folderLookupResult={folderResult}
onUseDetectedFolder={useDetectedSentFolder}
useDetectedFolderDisabled={disabled || !draftHasImap} />
useDetectedFolderDisabled={disabled || !draftHasImap}
initialSection={initialSection}
visibleSections={visibleSections}
mode={settingsPanelMode} />
}
</div>);
@@ -992,6 +1028,33 @@ function MailCredentialTreeCell({ profile, protocol }: {profile: MailServerProfi
}
function ProfileTransportSummary({ profile }: {profile: MailServerProfile;}) {
return (
<div className="mail-profile-transport-summary">
<div>
<span>SMTP server</span>
<strong>{transportLabel(profile.smtp)}</strong>
<small>{mailCredentialConfigured(profile, "smtp") ? "i18n:govoplan-mail.password_saved.f6fab237" : "i18n:govoplan-mail.no_saved_password.32ce2b16"}</small>
</div>
<div>
<span>IMAP server</span>
<strong>{transportLabel(profile.imap)}</strong>
<small>{profile.imap ? mailCredentialConfigured(profile, "imap") ? "i18n:govoplan-mail.password_saved.f6fab237" : "i18n:govoplan-mail.no_saved_password.32ce2b16" : "i18n:govoplan-mail.not_configured.811931bb"}</small>
</div>
</div>);
}
function MailServerTreeCell({ profile, protocol }: {profile: MailServerProfile;protocol: "smtp" | "imap";}) {
const transport = protocol === "smtp" ? profile.smtp : profile.imap;
return (
<div className="connection-tree-main">
<strong>{protocol.toUpperCase()} server</strong>
<div className="connection-tree-muted-line">{transportLabel(transport)}</div>
</div>);
}
function TransportCell({ profile, protocol }: {profile: MailServerProfile;protocol: "smtp" | "imap";}) {
const transport = protocol === "smtp" ? profile.smtp : profile.imap;
if (!transport) return <span className="muted">i18n:govoplan-mail.not_configured.811931bb</span>;
@@ -999,6 +1062,11 @@ function TransportCell({ profile, protocol }: {profile: MailServerProfile;protoc
return <div><strong>{transportLabel(transport)}</strong><div className="muted small-note">i18n:govoplan-mail.password.8be3c943 {hasPassword ? "configured" : "i18n:govoplan-mail.not_configured.67f2141f"}</div></div>;
}
function transportConfigured(profile: MailServerProfile, protocol: "smtp" | "imap"): boolean {
const transport = protocol === "smtp" ? profile.smtp : profile.imap;
return Boolean(transport?.host);
}
function mailCredentialConfigured(profile: MailServerProfile, protocol: "smtp" | "imap"): boolean {
return protocol === "smtp" ? profile.smtp_password_configured : profile.imap_password_configured;
}
@@ -1007,6 +1075,13 @@ function mailCredentialPolicySummary(policy: MailProfilePolicy | null, protocol:
return credentialInheritanceLabel(protocol === "smtp" ? policy?.smtp_credentials : policy?.imap_credentials);
}
function profileDialogTitle(editing: EditingProfile, target: MailProfileEditTarget): string {
if (editing === "new") return "i18n:govoplan-mail.create_mail_profile.4d2f8f9f";
if (target.kind === "server") return `Edit ${target.protocol.toUpperCase()} server`;
if (target.kind === "credentials") return i18nMessage("i18n:govoplan-mail.edit_value_credentials.1e2dc4f6", { value0: target.protocol.toUpperCase() });
return "i18n:govoplan-mail.edit_mail_profile.95d1af9c";
}
function emptyProfileDraft(): ProfileDraft {
return {
name: "",
@@ -1112,7 +1187,7 @@ function rawImapPayload(draft: ProfileDraft, preserveBlankPassword: boolean): Ma
}
function hasDraftImapSettings(draft: ProfileDraft): boolean {
return hasMailImapSettings([draft.imapHost, draft.imapUsername, draft.imapPassword]);
return hasMailImapSettings([draft.imapHost]);
}
function normalizePolicy(value: MailProfilePolicy | null | undefined): MailProfilePolicy {