mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Redesign Email Server settings + managed domain flow (#1373)
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
## Summary Rewrites the **Email Server** section of the project email settings page and the managed-domain setup flow. Replaces the dropdown + conditional-fields layout with a visual four-card picker, a clearer unsaved-state model, a stepper dialog for managed-domain onboarding, and a consistent tracked-domains list. Also fixes two data-correctness bugs in the managed-domain backend. ## Walkthrough (2×, dead-frames trimmed)  ## Before The saved state was a minimal dropdown, but choosing Custom SMTP / Resend revealed a long conditional form with a hidden gear toggle for server config, no clear "what is saved" signal, and a separate dialog pattern for managed domains. | Saved (Managed) | Custom SMTP selected | |---|---| |  |  | ## After — Provider cards Four visual cards (Stack Shared, Managed Domain, Resend, Custom SMTP) with updated copy. The saved provider shows a green **Current** pill; the card the user is previewing shows an amber dashed **Draft** pill. An amber unsaved-changes banner appears between the picker and the form when state diverges from saved, so it is unambiguous that a click is not yet committed. | Saved state | Previewing a different provider | |---|---| |  |  | Copy changes: - **Stack Shared** — "Only default emails — no custom templates, themes, or sender identity." (was: "Shared (noreply@stackframe.co)") - **Managed Domain** — "Bring your own domain. You add DNS records; we handle signing & delivery." (was: "Managed (via managed domain setup)") - **Resend** uses the official Resend brand mark (light/dark variants in `apps/dashboard/public/assets/`) ## After — Managed domain list + stepper dialog Selecting **Managed Domain** immediately shows the tracked-domain list with an **Add domain** button. Each row reflects real status (Active / Verified / Waiting for DNS / Verifying / Failed). Exactly one domain can be **Active** — the one matching the saved email config; every other verified/applied domain shows a **Use this domain** button so switching is always possible. Adding a domain opens a 3-stage dialog with a horizontal stepper (Verify is right-aligned for the final step). Stage 2 replaces the old bare NS-list with a proper **Type / Name / Content** DNS records table with per-row copy buttons. | Tracked domains list | DNS records table | |---|---| |  |  | ## Bug fixes - **Backend: applying a managed domain did not demote previously-applied ones.** Multiple rows could end up with status `APPLIED` even though only one could be in the saved config. New helper `demoteOtherAppliedManagedEmailDomains({ tenancyId, keepId })` runs inside `applyManagedEmailProvider` to demote all other applied rows in the tenancy back to `VERIFIED` before marking the new one. - **Frontend: "Use this domain" only appeared for `status === verified`.** A domain that had been applied then replaced could never be re-applied from the UI. Button now appears for any `verified` or `applied` row that is not currently in use; the **Active** label is derived from config match instead of DB status. - **Dev mock onboarding now mirrors production timing.** `shouldUseMockManagedEmailOnboarding()` used to insert domains as `verified` synchronously. Now the domain is created as `pending_verification`, and a fire-and-forget `runAsynchronously(() => wait(1000))` updates it to `verified` — mirroring the real Resend webhook flow so the UI states (pending → verifying → verified) are exercised in local dev. ## Test plan - [ ] Cards: clicking each card shows `Draft` pill + amber banner; Discard restores; Save commits and flips `Current` to the new card - [ ] Managed: Add domain → stage 1 input → stage 2 DNS table + copy → Check verification flips to stage 3 → Use this domain sets it Active and demotes the previously-active domain in the list - [ ] Managed: clicking **Use this domain** on a non-active verified row makes it Active and the previously-active row back to Verified - [ ] Shared / Resend / SMTP: existing save + test-email flows still work (logic preserved verbatim) - [ ] `pnpm typecheck` (dashboard + backend) and `pnpm lint` pass <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Redesigned email domain setup flow with multi-step verification dialog * Added copy-to-clipboard for DNS records * Enhanced provider selection interface with improved visual presentation * Onboarding now shows initial "pending verification" state and completes verification asynchronously * **Bug Fixes** * Ensures only one managed domain becomes active when applying a domain * Improved error handling for email configuration saves * **Tests** * Updated end-to-end tests to reflect async verification timing <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
parent
d1751a7634
commit
2f719903b1
@ -181,6 +181,22 @@ export async function updateManagedEmailDomainWebhookStatus(options: {
|
||||
return mapRow(rows[0]!);
|
||||
}
|
||||
|
||||
export async function demoteOtherAppliedManagedEmailDomains(options: {
|
||||
tenancyId: string,
|
||||
keepId: string,
|
||||
}): Promise<void> {
|
||||
await globalPrismaClient.$queryRaw(Prisma.sql`
|
||||
UPDATE "ManagedEmailDomain"
|
||||
SET
|
||||
"status" = 'VERIFIED'::"ManagedEmailDomainStatus",
|
||||
"appliedAt" = NULL,
|
||||
"updatedAt" = CURRENT_TIMESTAMP
|
||||
WHERE "tenancyId" = ${options.tenancyId}
|
||||
AND "id" <> ${options.keepId}
|
||||
AND "status" = 'APPLIED'::"ManagedEmailDomainStatus"
|
||||
`);
|
||||
}
|
||||
|
||||
export async function markManagedEmailDomainApplied(id: string): Promise<ManagedEmailDomain> {
|
||||
const rows = await globalPrismaClient.$queryRaw<ManagedEmailDomainRow[]>(Prisma.sql`
|
||||
UPDATE "ManagedEmailDomain"
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
ManagedEmailDomain,
|
||||
ManagedEmailDomainStatus,
|
||||
createManagedEmailDomain,
|
||||
demoteOtherAppliedManagedEmailDomains,
|
||||
getManagedEmailDomainByResendDomainId,
|
||||
getManagedEmailDomainByTenancyAndSubdomain,
|
||||
listManagedEmailDomainsForTenancy,
|
||||
@ -12,6 +13,7 @@ import {
|
||||
import { Tenancy } from "@/lib/tenancies";
|
||||
import { getNodeEnvironment, getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
|
||||
import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { runAsynchronously, wait } from "@stackframe/stack-shared/dist/utils/promises";
|
||||
|
||||
type ResendDomainRecord = {
|
||||
record: string,
|
||||
@ -543,7 +545,16 @@ export async function setupManagedEmailProvider(options: { subdomain: string, se
|
||||
senderLocalPart: options.senderLocalPart,
|
||||
resendDomainId: `managed_mock_${options.tenancy.id}_${normalizedSubdomain}`.replace(/[^a-zA-Z0-9_-]/g, "_"),
|
||||
nameServerRecords: ["ns1.dnsimple.com", "ns2.dnsimple.com"],
|
||||
status: "verified",
|
||||
status: "pending_verification",
|
||||
});
|
||||
runAsynchronously(async () => {
|
||||
await wait(1000);
|
||||
await updateManagedEmailDomainWebhookStatus({
|
||||
resendDomainId: row.resendDomainId,
|
||||
providerStatusRaw: "verified",
|
||||
status: "verified",
|
||||
lastError: null,
|
||||
});
|
||||
});
|
||||
return managedDomainToSetupResult(row);
|
||||
}
|
||||
@ -631,6 +642,10 @@ export async function applyManagedEmailProvider(options: {
|
||||
senderLocalPart: domain.senderLocalPart,
|
||||
});
|
||||
|
||||
await demoteOtherAppliedManagedEmailDomains({
|
||||
tenancyId: options.tenancy.id,
|
||||
keepId: domain.id,
|
||||
});
|
||||
await markManagedEmailDomainApplied(domain.id);
|
||||
return { status: "applied" };
|
||||
}
|
||||
|
||||
3
apps/dashboard/public/assets/resend-icon-black.svg
Normal file
3
apps/dashboard/public/assets/resend-icon-black.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="1800" height="1800" viewBox="0 0 1800 1800" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1000.46 450C1174.77 450 1278.43 553.669 1278.43 691.282C1278.43 828.896 1174.77 932.563 1000.46 932.563H912.382L1350 1350H1040.82L707.794 1033.48C683.944 1011.47 672.936 985.781 672.935 963.765C672.935 932.572 694.959 905.049 737.161 893.122L908.712 847.244C973.85 829.812 1018.81 779.353 1018.81 713.298C1018.8 632.567 952.745 585.78 871.095 585.78H450V450H1000.46Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 504 B |
3
apps/dashboard/public/assets/resend-icon-white.svg
Normal file
3
apps/dashboard/public/assets/resend-icon-white.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="1800" height="1800" viewBox="0 0 1800 1800" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1000.46 450C1174.77 450 1278.43 553.669 1278.43 691.282C1278.43 828.896 1174.77 932.563 1000.46 932.563H912.382L1350 1350H1040.82L707.794 1033.48C683.944 1011.47 672.936 985.781 672.935 963.765C672.935 932.572 694.959 905.049 737.161 893.122L908.712 847.244C973.85 829.812 1018.81 779.353 1018.81 713.298C1018.8 632.567 952.745 585.78 871.095 585.78H450V450H1000.46Z" fill="#FDFDFD"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 506 B |
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,4 @@
|
||||
import { wait } from "@stackframe/stack-shared/dist/utils/promises";
|
||||
import { describe } from "vitest";
|
||||
import { it } from "../../../../../helpers";
|
||||
import { Project, niceBackendFetch } from "../../../../backend-helpers";
|
||||
@ -48,7 +49,12 @@ describe("managed email onboarding internal endpoints", () => {
|
||||
|
||||
expect(setupResponse.status).toBe(200);
|
||||
expect(setupResponse.body.domain_id).toBeDefined();
|
||||
expect(setupResponse.body.status).toBe("verified");
|
||||
expect(setupResponse.body.status).toBe("pending_verification");
|
||||
|
||||
// Mock onboarding asynchronously flips status to "verified" ~1s after setup
|
||||
// (mirroring the real Resend webhook flow). Wait for the transition before
|
||||
// asserting verified state.
|
||||
await wait(1500);
|
||||
|
||||
const listResponse = await niceBackendFetch("/api/v1/internal/emails/managed-onboarding/list", {
|
||||
method: "GET",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user