diff --git a/.github/workflows/npm-publish.yaml b/.github/workflows/npm-publish.yaml index d6258759c..395393e66 100644 --- a/.github/workflows/npm-publish.yaml +++ b/.github/workflows/npm-publish.yaml @@ -36,12 +36,6 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Clean - run: pnpm run clean - - - name: Install dependencies (after clean) - run: pnpm install --frozen-lockfile - - name: Build packages run: pnpm build:packages diff --git a/apps/backend/src/lib/managed-email-onboarding.tsx b/apps/backend/src/lib/managed-email-onboarding.tsx index 854783748..4c0c9cd49 100644 --- a/apps/backend/src/lib/managed-email-onboarding.tsx +++ b/apps/backend/src/lib/managed-email-onboarding.tsx @@ -230,7 +230,10 @@ async function createDnsimpleZone(subdomain: string): Promise { async function deleteDnsimpleZoneByName(zoneName: string): Promise<{ status: "deleted" | "not_found" }> { const dnsimpleBaseUrl = getDnsimpleBaseUrl(); const dnsimpleAccountId = getDnsimpleAccountId(); - const response = await fetch(`${dnsimpleBaseUrl}/${encodeURIComponent(dnsimpleAccountId)}/zones/${encodeURIComponent(zoneName)}`, { + // A zone is created by creating a domain (see createDnsimpleZone), and DNSimple has no + // DELETE endpoint for zones — the symmetric teardown is to delete the owning domain, + // which removes its hosted DNS zone too. + const response = await fetch(`${dnsimpleBaseUrl}/${encodeURIComponent(dnsimpleAccountId)}/domains/${encodeURIComponent(zoneName)}`, { method: "DELETE", headers: getDnsimpleHeaders(), }); @@ -239,7 +242,7 @@ async function deleteDnsimpleZoneByName(zoneName: string): Promise<{ status: "de } if (!response.ok) { const responseBody = await response.text(); - throw new HexclaveAssertionError(`DNSimple returned non-OK status when deleting managed email zone`, { + throw new HexclaveAssertionError(`DNSimple returned non-OK status when deleting managed email domain`, { zoneName, status: response.status, responseBody, @@ -643,10 +646,19 @@ export async function applyManagedEmailProvider(options: { if (!domain || domain.tenancyId !== options.tenancy.id || !domain.isActive) { throw new StatusError(404, "Managed domain not found for this project/branch"); } - if (domain.status === "applied") { + // Only short-circuit when the rendered config already points at this exact domain. + // If the row is marked "applied" but the config has since drifted (e.g. the user + // switched to a different provider and back), we must fall through and re-provision + // so the config is rewritten with the full, valid managed server settings — otherwise + // the config is left missing required fields like password/senderName. + if (domain.status === "applied" && isManagedEmailDomainInUseForTenancy({ + tenancy: options.tenancy, + subdomain: domain.subdomain, + senderLocalPart: domain.senderLocalPart, + })) { return { status: "applied" }; } - if (domain.status !== "verified") { + if (domain.status !== "verified" && domain.status !== "applied") { throw new StatusError(409, "Managed domain is not verified yet"); } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-settings/domain-settings.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-settings/domain-settings.tsx index c66b1b2b7..5e0888509 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-settings/domain-settings.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-settings/domain-settings.tsx @@ -310,6 +310,7 @@ function ManagedDomainSetupDialog(props: { onOpenChange: (open: boolean) => void, initialState: SetupState | null, onCompleted: () => void, + onApply: (domainId: string) => void, }) { const stackAdminApp = useAdminApp(); const { toast } = useToast(); @@ -416,21 +417,14 @@ function ManagedDomainSetupDialog(props: { } }, [setupState, stackAdminApp, toast, props]); - const handleApply = useCallback(async () => { + const handleApply = useCallback(() => { if (!setupState) return; - setSubmitting(true); - setError(null); - try { - await stackAdminApp.applyManagedEmailProvider({ domainId: setupState.domainId }); - toast({ title: "Domain applied", description: `Sending emails from ${setupState.senderLocalPart}@${setupState.subdomain}.`, variant: "success" }); - props.onCompleted(); - props.onOpenChange(false); - } catch (e) { - setError(e instanceof Error ? e.message : "Could not apply domain"); - } finally { - setSubmitting(false); - } - }, [setupState, stackAdminApp, toast, props]); + // Don't apply immediately — stage the selection so the parent surfaces the + // standard "unsaved changes" save bar, consistent with the other providers. + // The actual applyManagedEmailProvider call happens when the user hits Save. + props.onApply(setupState.domainId); + props.onOpenChange(false); + }, [setupState, props]); const steps = [ { n: 1 as const, title: "Your domain" }, @@ -698,8 +692,7 @@ function ManagedDomainSetupDialog(props: { {stage === 3 && ( runAsynchronouslyWithAlert(handleApply)} + onClick={() => handleApply()} > Use this domain @@ -731,6 +724,9 @@ export function DomainSettings() { const [loadingDomains, setLoadingDomains] = useState(false); const [dialog, setDialog] = useState<{ initialState: SetupState | null } | null>(null); const [confirmDelete, setConfirmDelete] = useState(null); + // The managed domain the user has staged to apply but not yet saved. `null` means + // "no pending change" — the active domain (from config) is used as the baseline. + const [draftManagedDomainId, setDraftManagedDomainId] = useState(null); const refreshDomains = useCallback(async () => { setLoadingDomains(true); @@ -748,6 +744,15 @@ export function DomainSettings() { } }, [serverType, refreshDomains]); + // The managed domain currently applied per the saved config (the baseline). + const activeManagedDomainId = emailConfig.provider === "managed" && emailConfig.managedSubdomain && emailConfig.managedSenderLocalPart + ? domains.find((d) => + d.subdomain === emailConfig.managedSubdomain + && d.senderLocalPart === emailConfig.managedSenderLocalPart + )?.domainId ?? null + : null; + const isManagedDirty = serverType === "managed" && draftManagedDomainId != null && draftManagedDomainId !== activeManagedDomainId; + const isShared = serverType === "shared"; const visibleSenderFields = serverType === "resend" || serverType === "standard"; @@ -768,6 +773,7 @@ export function DomainSettings() { const isDirty = useMemo(() => { if (serverType !== savedServerType) return true; + if (isManagedDirty) return true; const keys = new Set(); if (visibleSenderFields) { keys.add("senderEmail"); @@ -778,7 +784,7 @@ export function DomainSettings() { if ((formValues[k] || "") !== (savedValues[k] || "")) return true; } return false; - }, [serverType, savedServerType, formValues, savedValues, visibleSenderFields, configFields]); + }, [serverType, savedServerType, isManagedDirty, formValues, savedValues, visibleSenderFields, configFields]); const updateField = useCallback((key: string, value: string) => { setFormValues(prev => ({ ...prev, [key]: value })); @@ -793,6 +799,7 @@ export function DomainSettings() { const handleDiscard = useCallback(() => { setServerType(savedServerType); setFormValues(savedValues); + setDraftManagedDomainId(null); setSaveError(null); }, [savedServerType, savedValues]); @@ -809,6 +816,22 @@ export function DomainSettings() { pushable: false, }); toast({ title: "Email server updated", variant: "success" }); + } else if (serverType === "managed") { + const domainId = draftManagedDomainId; + if (!domainId) throwErr("Select a domain to use"); + const selected = domains.find((d) => d.domainId === domainId); + if (!selected) throwErr("Selected domain is no longer available"); + // applyManagedEmailProvider provisions the scoped sending key and writes the complete, + // valid managed config server-side — it owns the managed config entirely. We must NOT + // write a partial config from here: a partial managed override renders to a config + // missing required fields (password/senderName), which breaks every project read. + // The empty updateConfig is a no-op write whose only purpose is to refresh the reactive + // config cache (via _refreshProjectConfig) so the UI reflects the newly-active domain. + await stackAdminApp.applyManagedEmailProvider({ domainId }); + await updateConfig({ adminApp: stackAdminApp, configUpdate: {}, pushable: false }); + setDraftManagedDomainId(null); + await refreshDomains(); + toast({ title: "Domain applied", description: `Sending emails from ${selected.senderLocalPart}@${selected.subdomain}.`, variant: "success" }); } else { const requireField = (key: string, label: string): string => { const val = formValues[key]; @@ -875,7 +898,7 @@ export function DomainSettings() { } finally { setSaving(false); } - }, [serverType, formValues, stackAdminApp, updateConfig, toast]); + }, [serverType, formValues, stackAdminApp, updateConfig, toast, draftManagedDomainId, domains, refreshDomains]); if (isEmulator) { return ( @@ -898,12 +921,15 @@ export function DomainSettings() { ...configFields.filter(f => !(formValues[f.key] || "").trim()).map(f => f.label), ] : []; - const canSave = isDirty && !emailFormatError && missingRequiredFields.length === 0; - const activeManagedDomainId = emailConfig.provider === "managed" && emailConfig.managedSubdomain && emailConfig.managedSenderLocalPart - ? domains.find((d) => - d.subdomain === emailConfig.managedSubdomain - && d.senderLocalPart === emailConfig.managedSenderLocalPart - )?.domainId + // Managed requires a staged domain selection before it can be saved. + const canSave = isDirty && !emailFormatError && missingRequiredFields.length === 0 + // For managed, also wait until the domains list has finished loading: a just-staged + // domain (e.g. one added via the setup dialog, which kicks off refreshDomains) may not + // be in `domains` yet, and saving early would spuriously fail with "Selected domain is + // no longer available". loadingDomains flips true synchronously when a refresh starts. + && (serverType !== "managed" || (draftManagedDomainId != null && !loadingDomains)); + const draftManagedDomain = draftManagedDomainId != null + ? domains.find((d) => d.domainId === draftManagedDomainId) ?? null : null; return ( @@ -971,14 +997,27 @@ export function DomainSettings() { })} - {isDirty && serverType !== "managed" && ( + {isDirty && (
- Unsaved changes — previewing{" "} - {PROVIDERS.find((p) => p.value === serverType)?.label}. - Changes don't take effect until you save. + {serverType === "managed" ? ( + draftManagedDomain ? ( + <> + Unsaved changes — save to start sending from{" "} + {draftManagedDomain.senderLocalPart}@{draftManagedDomain.subdomain}. + + ) : ( + <>Switching to a managed domain — select a verified domain below, then save. + ) + ) : ( + <> + Unsaved changes — previewing{" "} + {PROVIDERS.find((p) => p.value === serverType)?.label}. + Changes don't take effect until you save. + + )}
@@ -1030,11 +1069,12 @@ export function DomainSettings() { ) : (
{domains.map((d) => { - const isInUse = d.domainId === activeManagedDomainId; - const isReadyButUnused = !isInUse && (d.status === "verified" || d.status === "applied"); + const isActiveSaved = d.domainId === activeManagedDomainId; + const isDraftSelected = draftManagedDomainId != null && d.domainId === draftManagedDomainId && draftManagedDomainId !== activeManagedDomainId; + const isReadyButUnused = !isActiveSaved && !isDraftSelected && (d.status === "verified" || d.status === "applied"); const isPending = d.status === "pending_dns" || d.status === "pending_verification" || d.status === "failed"; - const displayStatus: ManagedDomainStatus = isInUse ? "applied" : isReadyButUnused ? "verified" : d.status; - const displayLabel = isInUse ? "Active" : MANAGED_DOMAIN_STATUS_LABELS[displayStatus]; + const displayStatus: ManagedDomainStatus = isActiveSaved ? "applied" : isReadyButUnused ? "verified" : d.status; + const displayLabel = isActiveSaved ? "Active" : MANAGED_DOMAIN_STATUS_LABELS[displayStatus]; return (
@@ -1044,30 +1084,41 @@ export function DomainSettings() { {d.senderLocalPart}@{d.subdomain}
- {isInUse ? "In use for this project" : "Not in use"} + {isActiveSaved ? "In use for this project" : isDraftSelected ? "Selected — save to apply" : "Not in use"}
- - {displayLabel} - + {isDraftSelected ? ( + + Selected + + ) : ( + + {displayLabel} + + )} {isReadyButUnused && ( runAsynchronouslyWithAlert(async () => { - await stackAdminApp.applyManagedEmailProvider({ domainId: d.domainId }); - toast({ title: "Domain applied", description: `Sending from ${d.senderLocalPart}@${d.subdomain}.`, variant: "success" }); - await refreshDomains(); - })} + onClick={() => setDraftManagedDomainId(d.domainId)} > Use this domain )} + {isDraftSelected && ( + setDraftManagedDomainId(null)} + > + Deselect + + )} {isPending && ( )} - {!isInUse && ( + {!isActiveSaved && (