mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
fix(email): correct DNSimple zone teardown and managed-domain apply flow
deleteDnsimpleZoneByName: DNSimple has no DELETE /zones endpoint. Zones are
created by creating a domain, so the symmetric teardown is DELETE /domains/{name},
which also removes the hosted zone. Previously every managed-domain deletion threw
"DNSimple returned non-OK status when deleting managed email zone".
applyManagedEmailProvider: only short-circuit on status "applied" when the config
actually uses the domain (isManagedEmailDomainInUseForTenancy). A row marked
applied whose config has since drifted now re-provisions and rewrites the full
managed config, instead of leaving it missing password/senderName — which makes
the rendered config invalid and 500s every project read.
Email-settings dashboard: applying a managed domain now uses the standard staged
"unsaved changes -> Save" flow like the other providers, instead of an immediate
API call that never refreshed the config the UI derives "active" from. Selecting a
domain stages a draft; Save calls applyManagedEmailProvider (which owns the full
config write) then refreshes the reactive config cache.
This commit is contained in:
parent
1e4f4e477f
commit
1f77f038e0
@ -230,7 +230,10 @@ async function createDnsimpleZone(subdomain: string): Promise<DnsimpleZone> {
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@ -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 && (
|
||||
<DesignButton
|
||||
size="sm"
|
||||
loading={submitting}
|
||||
onClick={() => runAsynchronouslyWithAlert(handleApply)}
|
||||
onClick={() => handleApply()}
|
||||
>
|
||||
Use this domain
|
||||
</DesignButton>
|
||||
@ -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<ManagedDomain | null>(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<string | null>(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<string>();
|
||||
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,11 @@ 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
|
||||
&& (serverType !== "managed" || draftManagedDomainId != null);
|
||||
const draftManagedDomain = draftManagedDomainId != null
|
||||
? domains.find((d) => d.domainId === draftManagedDomainId) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
@ -971,14 +993,27 @@ export function DomainSettings() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isDirty && serverType !== "managed" && (
|
||||
{isDirty && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/[0.06] p-3 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-foreground">
|
||||
<WarningDiamond className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0" weight="fill" />
|
||||
<span>
|
||||
Unsaved changes — previewing{" "}
|
||||
<span className="font-semibold">{PROVIDERS.find((p) => p.value === serverType)?.label}</span>.
|
||||
Changes don't take effect until you save.
|
||||
{serverType === "managed" ? (
|
||||
draftManagedDomain ? (
|
||||
<>
|
||||
Unsaved changes — save to start sending from{" "}
|
||||
<span className="font-semibold">{draftManagedDomain.senderLocalPart}@{draftManagedDomain.subdomain}</span>.
|
||||
</>
|
||||
) : (
|
||||
<>Switching to a managed domain — select a verified domain below, then save.</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
Unsaved changes — previewing{" "}
|
||||
<span className="font-semibold">{PROVIDERS.find((p) => p.value === serverType)?.label}</span>.
|
||||
Changes don't take effect until you save.
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
@ -1030,11 +1065,12 @@ export function DomainSettings() {
|
||||
) : (
|
||||
<div className="rounded-lg border border-border/60 divide-y divide-border/50 overflow-hidden">
|
||||
{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 (
|
||||
<div key={d.domainId} className="flex items-center justify-between px-4 py-3 gap-3 min-w-0">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
@ -1044,30 +1080,41 @@ export function DomainSettings() {
|
||||
{d.senderLocalPart}@{d.subdomain}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5">
|
||||
{isInUse ? "In use for this project" : "Not in use"}
|
||||
{isActiveSaved ? "In use for this project" : isDraftSelected ? "Selected — save to apply" : "Not in use"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={cn(
|
||||
"text-[11px] font-medium px-2 py-0.5 rounded-full whitespace-nowrap",
|
||||
MANAGED_DOMAIN_STATUS_COLORS[displayStatus],
|
||||
)}>
|
||||
{displayLabel}
|
||||
</span>
|
||||
{isDraftSelected ? (
|
||||
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full whitespace-nowrap text-amber-700 dark:text-amber-400 bg-amber-500/10">
|
||||
Selected
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn(
|
||||
"text-[11px] font-medium px-2 py-0.5 rounded-full whitespace-nowrap",
|
||||
MANAGED_DOMAIN_STATUS_COLORS[displayStatus],
|
||||
)}>
|
||||
{displayLabel}
|
||||
</span>
|
||||
)}
|
||||
{isReadyButUnused && (
|
||||
<DesignButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => 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
|
||||
</DesignButton>
|
||||
)}
|
||||
{isDraftSelected && (
|
||||
<DesignButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setDraftManagedDomainId(null)}
|
||||
>
|
||||
Deselect
|
||||
</DesignButton>
|
||||
)}
|
||||
{isPending && (
|
||||
<DesignButton
|
||||
size="sm"
|
||||
@ -1083,7 +1130,7 @@ export function DomainSettings() {
|
||||
View DNS
|
||||
</DesignButton>
|
||||
)}
|
||||
{!isInUse && (
|
||||
{!isActiveSaved && (
|
||||
<button
|
||||
type="button"
|
||||
title="Remove domain"
|
||||
@ -1174,6 +1221,11 @@ export function DomainSettings() {
|
||||
onOpenChange={(o) => { if (!o) setDialog(null); }}
|
||||
initialState={dialog?.initialState ?? null}
|
||||
onCompleted={() => { runAsynchronouslyWithAlert(refreshDomains); }}
|
||||
onApply={(domainId) => {
|
||||
setServerType("managed");
|
||||
setDraftManagedDomainId(domainId);
|
||||
runAsynchronouslyWithAlert(refreshDomains);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ActionDialog
|
||||
@ -1185,9 +1237,11 @@ export function DomainSettings() {
|
||||
label: "Remove",
|
||||
onClick: async () => {
|
||||
if (!confirmDelete) return;
|
||||
const removed = confirmDelete;
|
||||
runAsynchronouslyWithAlert(async () => {
|
||||
await stackAdminApp.deleteManagedEmailDomain({ resendDomainId: confirmDelete.domainId });
|
||||
toast({ title: "Domain removed", description: `${confirmDelete.senderLocalPart}@${confirmDelete.subdomain} was removed.`, variant: "success" });
|
||||
await stackAdminApp.deleteManagedEmailDomain({ resendDomainId: removed.domainId });
|
||||
toast({ title: "Domain removed", description: `${removed.senderLocalPart}@${removed.subdomain} was removed.`, variant: "success" });
|
||||
if (draftManagedDomainId === removed.domainId) setDraftManagedDomainId(null);
|
||||
await refreshDomains();
|
||||
});
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user