diff --git a/webui/package-lock.json b/webui/package-lock.json index b8fdd36..f985180 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -13,9 +13,9 @@ "peerDependencies": { "@govoplan/core-webui": "^0.1.10", "lucide-react": "^1.23.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-router-dom": ">=7.18.2 <8" + "react": ">=19.2.7 <20", + "react-dom": ">=19.2.7 <20", + "react-router": ">=8.3.0 <9" }, "peerDependenciesMeta": { "@govoplan/core-webui": { @@ -23,107 +23,6 @@ } } }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/lucide-react": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz", - "integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==", - "license": "ISC", - "peer": true, - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.7" - } - }, - "node_modules/react-router": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", - "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", - "license": "MIT", - "peer": true, - "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "node_modules/react-router-dom": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", - "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", - "license": "MIT", - "peer": true, - "dependencies": { - "react-router": "7.18.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true - }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT", - "peer": true - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/webui/package.json b/webui/package.json index 61c8f16..645b885 100644 --- a/webui/package.json +++ b/webui/package.json @@ -16,9 +16,9 @@ "peerDependencies": { "@govoplan/core-webui": "^0.1.10", "lucide-react": "^1.23.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-router-dom": ">=7.18.2 <8" + "react": ">=19.2.7 <20", + "react-dom": ">=19.2.7 <20", + "react-router": ">=8.3.0 <9" }, "peerDependenciesMeta": { "@govoplan/core-webui": { diff --git a/webui/src/features/mail/MailProfileManagement.tsx b/webui/src/features/mail/MailProfileManagement.tsx index e3a7690..8b9ef9e 100644 --- a/webui/src/features/mail/MailProfileManagement.tsx +++ b/webui/src/features/mail/MailProfileManagement.tsx @@ -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(null); const [editingTarget, setEditingTarget] = useState({ kind: "create" }); + const [createStage, setCreateStage] = useState("profile"); const [deactivating, setDeactivating] = useState(null); const [pendingHierarchyRemoval, setPendingHierarchyRemoval] = useState(null); const [availableCredentials, setAvailableCredentials] = useState([]); @@ -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 { 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={<>}> + footer={editing === "new" + ? <> + + {createStageIndex > 0 && ( + + )} + {createStageIndex < mailProfileCreateStages.length - 1 + ? ( + + ) + : ( + + )} + + : <>}> + {editing === "new" && ( + ({ + id: stage, + label: mailProfileCreateStageLabel(stage), + icon: mailProfileCreateStageIcon(stage), + current: stage === createStage, + tone: index < createStageIndex ? "success" : stage === createStage ? "active" : "neutral", + connectorTone: index < createStageIndex ? "success" : "neutral" + }))} + /> + )} @@ -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({ } - {policyMessages.length > 0 && + {policyMessages.length > 0 && ( + editTarget.kind !== "create" + || createStage === "smtp_server" + || createStage === "imap_server" + ) && `${item.key}:${item.value}`).join("|")} dismissible={false}> i18n:govoplan-mail.effective_mail_policy_blocks_the_current_profile.1b555820
    {policyMessages.map((item) =>
  • {item.message}
  • )}
@@ -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 ; + if (stage === "smtp_server") return ; + if (stage === "imap_server") return ; + return ; +} + 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`; diff --git a/webui/src/features/mail/mailProfileEditorModel.ts b/webui/src/features/mail/mailProfileEditorModel.ts index 0040935..9b085d8 100644 --- a/webui/src/features/mail/mailProfileEditorModel.ts +++ b/webui/src/features/mail/mailProfileEditorModel.ts @@ -1,6 +1,28 @@ export type MailProfileProtocol = "smtp" | "imap"; export type MailProfileEditSection = MailProfileProtocol; export type MailProfilePanelMode = "all" | "server" | "credentials"; +export type MailProfileCreateStage = + | "profile" + | "smtp_server" + | "smtp_credentials" + | "imap_server" + | "imap_credentials"; + +export const mailProfileCreateStages: readonly MailProfileCreateStage[] = [ + "profile", + "smtp_server", + "smtp_credentials", + "imap_server", + "imap_credentials" +]; + +export type MailProfileCreateStageFields = { + name: string; + smtpHost: string; + imapHost: string; + imapUsername: string; + imapPassword: string; +}; export type MailProfileEditTarget = | {kind: "create";} @@ -75,6 +97,49 @@ export function mailProfileEditTargetShowsSettingsPanel(target: MailProfileEditT return target.kind !== "profile"; } +export function mailProfileCreateStagePanel( + stage: MailProfileCreateStage +): { + showProfileFields: boolean; + showSettingsPanel: boolean; + initialSection: MailProfileEditSection; + visibleSections: MailProfileEditSection[]; + panelMode: MailProfilePanelMode | null; +} { + if (stage === "profile") { + return { + showProfileFields: true, + showSettingsPanel: false, + initialSection: "smtp", + visibleSections: [], + panelMode: null + }; + } + const protocol = stage.startsWith("smtp") ? "smtp" : "imap"; + return { + showProfileFields: false, + showSettingsPanel: true, + initialSection: protocol, + visibleSections: [protocol], + panelMode: stage.endsWith("credentials") ? "credentials" : "server" + }; +} + +export function mailProfileCreateStageCanContinue( + stage: MailProfileCreateStage, + fields: MailProfileCreateStageFields +): boolean { + if (stage === "profile") return Boolean(fields.name.trim()); + if (stage === "smtp_server") return Boolean(fields.smtpHost.trim()); + if ( + stage === "imap_credentials" + && (fields.imapUsername.trim() || fields.imapPassword) + ) { + return Boolean(fields.imapHost.trim()); + } + return true; +} + /** * Keep the PATCH authority surface equal to the focused editor. Profile and * server edits must not accidentally replay credential fields merely because diff --git a/webui/tests/mail-profile-editor-model.test.ts b/webui/tests/mail-profile-editor-model.test.ts index 5517633..0f58e0d 100644 --- a/webui/tests/mail-profile-editor-model.test.ts +++ b/webui/tests/mail-profile-editor-model.test.ts @@ -19,6 +19,9 @@ import { mailProfileEditTargetShowsProfileFields, mailProfileEditTargetShowsSettingsPanel, mailProfileEditTargetVisibleSections, + mailProfileCreateStageCanContinue, + mailProfileCreateStagePanel, + mailProfileCreateStages, mailProfileCreateCredentialsPayload, mailProfileTargetedUpdatePayload } from "../src/features/mail/mailProfileEditorModel"; @@ -48,6 +51,68 @@ assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "profile" }), true); assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "server", protocol: "smtp" }), false); assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "profile" }), false); assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "create" }), true); +assertDeepEqual( + mailProfileCreateStages, + [ + "profile", + "smtp_server", + "smtp_credentials", + "imap_server", + "imap_credentials" + ], + "profile creation keeps server and credential stages explicit" +); +assertDeepEqual( + mailProfileCreateStagePanel("smtp_credentials"), + { + showProfileFields: false, + showSettingsPanel: true, + initialSection: "smtp", + visibleSections: ["smtp"], + panelMode: "credentials" + } +); +assertDeepEqual( + mailProfileCreateStagePanel("imap_server"), + { + showProfileFields: false, + showSettingsPanel: true, + initialSection: "imap", + visibleSections: ["imap"], + panelMode: "server" + } +); +assertEqual( + mailProfileCreateStageCanContinue("profile", { + name: "", + smtpHost: "", + imapHost: "", + imapUsername: "", + imapPassword: "" + }), + false +); +assertEqual( + mailProfileCreateStageCanContinue("smtp_server", { + name: "Profile", + smtpHost: "smtp.example.org", + imapHost: "", + imapUsername: "", + imapPassword: "" + }), + true +); +assertEqual( + mailProfileCreateStageCanContinue("imap_credentials", { + name: "Profile", + smtpHost: "smtp.example.org", + imapHost: "", + imapUsername: "imap-user", + imapPassword: "" + }), + false, + "IMAP credentials cannot be submitted without an IMAP server" +); const updateParts = { profile: { name: "Renamed" },