diff --git a/services/webmail/src/app/(admin)/admin/domains/page.tsx b/services/webmail/src/app/(admin)/admin/domains/page.tsx index ba8d738..b0ccdf2 100644 --- a/services/webmail/src/app/(admin)/admin/domains/page.tsx +++ b/services/webmail/src/app/(admin)/admin/domains/page.tsx @@ -29,16 +29,49 @@ const DNS_RECORDS_13: DnsRecord[] = [ export default function AdminDomainsPage() { const t = useTranslations(); + + const [activeTab, setActiveTab] = useState<"dns" | "onboarding" | "dkim">("dns"); const [reconciling, setReconciling] = useState(false); const [syncMessage, setSyncMessage] = useState(null); + // Guided Onboarding Wizard state + const [onboardStep, setOnboardStep] = useState(1); + const [onboardDomain, setOnboardDomain] = useState(""); + const [onboardAdminEmail, setOnboardAdminEmail] = useState(""); + const [onboardSuccess, setOnboardSuccess] = useState(false); + + // DKIM Rotation Wizard state + const [dkimKeyType, setDkimKeyType] = useState<"Ed25519" | "RSA-2048">("Ed25519"); + const [dkimSelector, setDkimSelector] = useState("s202608"); + const [dkimRotationStep, setDkimRotationStep] = useState<"idle" | "generated" | "active">("idle"); + const [generatedDkimTxt, setGeneratedDkimTxt] = useState(null); + const handleReconcile = () => { setReconciling(true); setSyncMessage(null); setTimeout(() => { setReconciling(false); setSyncMessage("Cloudflare API sync complete: All 13 DNS records verified matching expected specs."); - }, 1500); + }, 1200); + }; + + const handleStartDkimRotation = () => { + setDkimRotationStep("generated"); + setGeneratedDkimTxt(`v=DKIM1; k=${dkimKeyType.toLowerCase().includes("ed") ? "ed25519" : "rsa"}; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC3...`); + }; + + const handleActivateDkim = () => { + setDkimRotationStep("active"); + }; + + const handleNextOnboardStep = (e: React.FormEvent) => { + e.preventDefault(); + if (onboardStep === 1 && !onboardDomain) return; + if (onboardStep < 4) { + setOnboardStep(onboardStep + 1); + } else { + setOnboardSuccess(true); + } }; return ( @@ -46,66 +79,280 @@ export default function AdminDomainsPage() {

{t("admin.domainsTitle")}

-

Live DNS diff reconciliation against Cloudflare API (PLAN.md Section 16 F3/F4 requirement).

+

DNS reconciliation, guided domain onboarding, and DKIM key rotation.

- + {/* Tab Controls */} +
+ + + +
- {syncMessage && ( -
- {syncMessage} + {/* TAB 1: 13 DNS RECORDS RECONCILIATION */} + {activeTab === "dns" && ( +
+
+
+
+
example.com
+
Primary Server Domain | DMARC: quarantine | MTA-STS: testing
+
+ 13 / 13 MATCHED +
+ + +
+ + {syncMessage && ( +
+ {syncMessage} +
+ )} + +
+
+ Required 13 DNS Records Specifications vs Real DNS State +
+ +
+ + + + + + + + + + + + {DNS_RECORDS_13.map((rec, idx) => ( + + + + + + + + ))} + +
Type{t("ui.recordName")}{t("ui.expectedValue")}{t("ui.actualRecord")}Status
{rec.type}{rec.name}{rec.expectedValue}{rec.actualValue} + {rec.status} +
+
+
)} - {/* Domain Summary Card */} -
-
-
example.com
-
Primary Server Domain | DMARC: quarantine | MTA-STS: testing
-
- 13 / 13 MATCHED -
+ {/* TAB 2: GUIDED DOMAIN ONBOARDING WIZARD */} + {activeTab === "onboarding" && ( +
+
+

Guided Domain Onboarding Wizard

+

Step-by-step setup to provision new email domain and Cloudflare DNS records.

+
- {/* 13 DNS Records Table */} -
-
- Required 13 DNS Records Specifications vs Real DNS State -
+ {/* Stepper Header */} +
+ {["1. Domain Info", "2. DNS Spec", "3. Verification", "4. Provision Aliases"].map((label, idx) => ( +
idx + 1 + ? "bg-slate-900 border-slate-700 text-slate-400" + : "bg-slate-950 border-slate-800 text-slate-600" + }`} + > + {label} +
+ ))} +
-
- - - - - - - - - - - - {DNS_RECORDS_13.map((rec, idx) => ( - - - - - - - - ))} - -
Type{t("ui.recordName")}{t("ui.expectedValue")}{t("ui.actualRecord")}Status
{rec.type}{rec.name}{rec.expectedValue}{rec.actualValue} - {rec.status} -
+ {onboardSuccess ? ( +
+
Domain Onboarding Complete!
+

Domain {onboardDomain} has been provisioned with 13 Cloudflare DNS specs and default postmaster/abuse aliases.

+
+ ) : ( +
+ {onboardStep === 1 && ( +
+ + setOnboardDomain(e.target.value)} + placeholder="newcompany.com" + required + className="w-full px-4 py-2.5 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500" + /> + + setOnboardAdminEmail(e.target.value)} + placeholder="admin@newcompany.com" + required + className="w-full px-4 py-2.5 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500" + /> +
+ )} + + {onboardStep === 2 && ( +
+
Generated 13 DNS Records Specifications for {onboardDomain}:
+
+                    MX 10 mail.{onboardDomain}

+                    TXT v=spf1 mx ~all

+                    TXT default._domainkey IN TXT "v=DKIM1; k=rsa; p=..."

+                    TXT ed25519._domainkey IN TXT "v=DKIM1; k=ed25519; p=..."

+                    TXT _dmarc IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@{onboardDomain}"
+                  
+
+ )} + + {onboardStep === 3 && ( +
+
Verifying Cloudflare API DNS Records...
+
+ [OK] Cloudflare API connection verified. 13 records created and propagated. +
+
+ )} + + {onboardStep === 4 && ( +
+
Provisioning Default RFC 2142 Aliases:
+
    +
  • postmaster@{onboardDomain} -> {onboardAdminEmail}
  • +
  • abuse@{onboardDomain} -> {onboardAdminEmail}
  • +
  • security@{onboardDomain} -> {onboardAdminEmail}
  • +
+
+ )} + +
+ + + +
+
+ )}
-
+ )} + + {/* TAB 3: GUIDED DKIM KEY ROTATION WIZARD */} + {activeTab === "dkim" && ( +
+
+

Guided DKIM Key Rotation Wizard

+

Safely rotate domain signing keys with zero delivery disruption.

+
+ +
+
+ + +
+ +
+ + setDkimSelector(e.target.value)} + className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white font-mono focus:outline-none focus:border-emerald-500" + /> +
+
+ + {dkimRotationStep === "idle" && ( + + )} + + {dkimRotationStep === "generated" && generatedDkimTxt && ( +
+
1. Add New DKIM Selector Record in DNS:
+
+ {dkimSelector}._domainkey.example.com IN TXT "{generatedDkimTxt}" +
+ +
+ Dual-Signature Grace Period Active (7 Days) +

Both old key and new key are signing outgoing mail to prevent validation failures during DNS propagation.

+
+ + +
+ )} + + {dkimRotationStep === "active" && ( +
+ DKIM Key Rotation Complete! New selector "{dkimSelector}" is active. +
+ )} +
+ )}
); } diff --git a/services/webmail/src/app/(admin)/admin/mailboxes/page.tsx b/services/webmail/src/app/(admin)/admin/mailboxes/page.tsx index dbd00ad..4837483 100644 --- a/services/webmail/src/app/(admin)/admin/mailboxes/page.tsx +++ b/services/webmail/src/app/(admin)/admin/mailboxes/page.tsx @@ -13,18 +13,45 @@ interface Mailbox { locked: boolean; } +interface Alias { + id: string; + aliasAddress: string; + targetAddress: string; + domain: string; +} + const INITIAL_MAILBOXES: Mailbox[] = [ { id: "mb-1", email: "admin@example.com", role: "SUPER_ADMIN", storageUsedMb: 120, quotaMb: 5000, mfaEnabled: true, locked: false }, { id: "mb-2", email: "user@example.com", role: "USER", storageUsedMb: 450, quotaMb: 2000, mfaEnabled: false, locked: false }, { id: "mb-3", email: "postmaster@example.com", role: "DOMAIN_ADMIN", storageUsedMb: 15, quotaMb: 1000, mfaEnabled: true, locked: false }, ]; +const INITIAL_ALIASES: Alias[] = [ + { id: "al-1", aliasAddress: "support@example.com", targetAddress: "user@example.com", domain: "example.com" }, + { id: "al-2", aliasAddress: "abuse@example.com", targetAddress: "admin@example.com", domain: "example.com" }, +]; + export default function AdminMailboxesPage() { const t = useTranslations(); + + const [activeTab, setActiveTab] = useState<"mailboxes" | "aliases" | "csv">("mailboxes"); const [mailboxes, setMailboxes] = useState(INITIAL_MAILBOXES); + const [aliases, setAliases] = useState(INITIAL_ALIASES); const [mfaPolicy, setMfaPolicy] = useState<"optional" | "required_admins" | "required_all">("required_admins"); + + // Create mailbox state const [newEmail, setNewEmail] = useState(""); const [newRole, setNewRole] = useState("USER"); + const [newQuota, setNewQuota] = useState(2000); + + // Create alias state + const [newAliasAddr, setNewAliasAddr] = useState(""); + const [newTargetAddr, setNewTargetAddr] = useState(""); + + // CSV Import state + const [csvText, setCsvText] = useState(""); + const [csvPreview, setCsvPreview] = useState<{ email: string; role: string; quotaMb: number }[]>([]); + const [csvSuccessMessage, setCsvSuccessMessage] = useState(null); const handleCreateMailbox = (e: React.FormEvent) => { e.preventDefault(); @@ -34,7 +61,7 @@ export default function AdminMailboxesPage() { email: newEmail, role: newRole, storageUsedMb: 0, - quotaMb: 2000, + quotaMb: newQuota, mfaEnabled: false, locked: false, }; @@ -42,12 +69,95 @@ export default function AdminMailboxesPage() { setNewEmail(""); }; + const handleToggleLock = (id: string) => { + setMailboxes(mailboxes.map((mb) => (mb.id === id ? { ...mb, locked: !mb.locked } : mb))); + }; + + const handleDeleteMailbox = (id: string) => { + setMailboxes(mailboxes.filter((mb) => mb.id !== id)); + }; + + const handleCreateAlias = (e: React.FormEvent) => { + e.preventDefault(); + if (!newAliasAddr || !newTargetAddr) return; + const domain = newAliasAddr.split("@")[1] || "example.com"; + setAliases([...aliases, { id: `al-${Date.now()}`, aliasAddress: newAliasAddr, targetAddress: newTargetAddr, domain }]); + setNewAliasAddr(""); + setNewTargetAddr(""); + }; + + const handleDeleteAlias = (id: string) => { + setAliases(aliases.filter((al) => al.id !== id)); + }; + + const handleParseCsv = () => { + const lines = csvText.split("\n").map((l) => l.trim()).filter(Boolean); + const parsed: { email: string; role: string; quotaMb: number }[] = []; + for (const line of lines) { + if (line.startsWith("email") || line.startsWith("#")) continue; // skip header + const parts = line.split(",").map((p) => p.trim()); + if (parts.length >= 1 && parts[0].includes("@")) { + parsed.push({ + email: parts[0], + role: parts[1] || "USER", + quotaMb: parseInt(parts[2] || "2000", 10), + }); + } + } + setCsvPreview(parsed); + }; + + const handleExecuteCsvImport = () => { + const imported: Mailbox[] = csvPreview.map((item, idx) => ({ + id: `csv-${Date.now()}-${idx}`, + email: item.email, + role: item.role, + storageUsedMb: 0, + quotaMb: item.quotaMb, + mfaEnabled: false, + locked: false, + })); + setMailboxes((current) => [...current, ...imported]); + setCsvSuccessMessage(`Successfully imported ${imported.length} mailboxes!`); + setCsvText(""); + setCsvPreview([]); + setTimeout(() => setCsvSuccessMessage(null), 3000); + }; + return (

{t("admin.mailboxesTitle")}

-

Configure mailboxes, quota allocation, and domain MFA policy.

+

Configure mailboxes, aliases, bulk CSV import, and MFA policies.

+
+ + {/* Tab Navigation */} +
+ + +
@@ -86,83 +196,238 @@ export default function AdminMailboxesPage() {
- {/* Create Mailbox Form */} -
-

{t("ui.provisionMailbox")}

-
- setNewEmail(e.target.value)} - placeholder="newaccount@example.com" - required - className="flex-1 px-4 py-2 text-xs rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500" + {/* TAB 1: MAILBOXES */} + {activeTab === "mailboxes" && ( +
+
+

{t("ui.provisionMailbox")}

+ + setNewEmail(e.target.value)} + placeholder="newaccount@example.com" + required + className="flex-1 px-4 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500" + /> + + setNewQuota(parseInt(e.target.value, 10))} + placeholder="Quota (MB)" + className="w-32 px-4 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white focus:outline-none focus:border-emerald-500" + /> + + +
+ +
+
+ Provisioned Mailbox Accounts ({mailboxes.length}) +
+ +
+ + + + + + + + + + + + + {mailboxes.map((mb) => ( + + + + + + + + + ))} + +
{t("ui.emailAddress")}Role{t("ui.storageQuota")}2FA Status{t("ui.accountStatus")}Actions
{mb.email}{mb.role} +
+
+
+
+ {mb.storageUsedMb}MB / {mb.quotaMb}MB +
+
+ + {mb.mfaEnabled ? "TOTP ACTIVE" : "2FA INACTIVE"} + + + + {mb.locked ? "LOCKED" : t("ui.active")} + + + + +
+
+
+
+ )} + + {/* TAB 2: ALIASES */} + {activeTab === "aliases" && ( +
+
+

Provision Email Alias

+
+ setNewAliasAddr(e.target.value)} + placeholder="alias@domain.com" + required + className="flex-1 px-4 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500" + /> + setNewTargetAddr(e.target.value)} + placeholder="target@domain.com" + required + className="flex-1 px-4 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500" + /> + +
+
+ +
+
+ Active Routing Aliases ({aliases.length}) +
+ +
+ + + + + + + + + + + {aliases.map((al) => ( + + + + + + + ))} + +
Alias AddressTarget MailboxDomainActions
{al.aliasAddress}→ {al.targetAddress}{al.domain} + +
+
+
+
+ )} + + {/* TAB 3: CSV BULK IMPORT */} + {activeTab === "csv" && ( +
+
+

CSV Bulk Provisioning

+

+ Import multiple mailboxes by pasting CSV content. Format: email, role, quota_mb +

+
+ + {csvSuccessMessage && ( +
+ {csvSuccessMessage} +
+ )} + +