improve setup

This commit is contained in:
Hieu Nguyen (Jack) committed 2026-09-18 16:47:03 +07:00
1 parent 76adbc8ebd
commit dd67f90cf3
6 files changed
+166 -33

No files matched your search

+82 -21
View File
@@ -6,12 +6,14 @@ import { AlertTriangle, ArrowRight, CheckCircle2, LoaderCircle, MailPlus, XCircl
import { useEffect, useState } from "react";
import { AuthShell } from "@/components/auth/auth-shell";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { TurnstileField } from "@/components/auth/turnstile";
import {
getSetupStatus,
checkExistingMx,
prepareSetup,
submitPrimaryDomain,
submitRegistration,
@@ -36,7 +38,10 @@ export function RegisterClient() {
const [databaseMigrated, setDatabaseMigrated] = useState(false);
const [preparationComplete, setPreparationComplete] = useState(false);
const [error, setError] = useState<string | null>(null);
const [mxConflict, setMxConflict] = useState(false);
const [mxChecking, setMxChecking] = useState(true);
const [mxRecordsExist, setMxRecordsExist] = useState<boolean | null>(null);
const [replaceMxRecords, setReplaceMxRecords] = useState(false);
const [mxCheckRevision, setMxCheckRevision] = useState(0);
const [loading, setLoading] = useState(false);
const [turnstileReset, setTurnstileReset] = useState(0);
@@ -44,6 +49,38 @@ export function RegisterClient() {
void runPreparation();
}, []);
const accountDomain = setupDomain ?? primaryDomain;
useEffect(() => {
if (step !== 3 || !accountDomain) return;
let active = true;
setMxChecking(true);
setMxRecordsExist(null);
setReplaceMxRecords(false);
setError(null);
void checkExistingMx(accountDomain)
.then(({ ok, data }) => {
if (!active) return;
setMxChecking(false);
if (!ok || data.hasExistingMx === undefined) {
setError(typeof data.error === "string" ? data.error : "Could not check existing MX records");
return;
}
setMxRecordsExist(data.hasExistingMx);
})
.catch((error: unknown) => {
if (!active) return;
setMxChecking(false);
setError(error instanceof Error ? error.message : "Could not check existing MX records");
});
return () => {
active = false;
};
}, [step, accountDomain, mxCheckRevision]);
async function runPreparation() {
setLoading(true);
setError(null);
@@ -119,8 +156,6 @@ export function RegisterClient() {
setError(null);
const form = new FormData(e.currentTarget);
const submitter = (e.nativeEvent as SubmitEvent).submitter as HTMLButtonElement | null;
const replaceMxRecords = submitter?.value === "true";
const domain = setupDomain ?? primaryDomain;
if (!domain) {
setLoading(false);
@@ -139,7 +174,8 @@ export function RegisterClient() {
setLoading(false);
if (!ok) {
if (data.code === "MX_RECORDS_CONFLICT") {
setMxConflict(true);
setMxRecordsExist(true);
setReplaceMxRecords(false);
setError(null);
setTurnstileReset((value) => value + 1);
return;
@@ -150,10 +186,9 @@ export function RegisterClient() {
setTurnstileReset((value) => value + 1);
return;
}
router.push(data.redirect ?? "/inbox");
window.location.assign(data.redirect ?? "/login");
}
const accountDomain = setupDomain ?? primaryDomain;
const showDomainStep = hasPrimaryDomain === false && step === 2;
if (hasAdminAccount === true) {
@@ -324,6 +359,36 @@ export function RegisterClient() {
</form>
) : (
<form method="post" onSubmit={onSubmit} className="space-y-5">
{mxChecking && (
<div className="flex items-center gap-3 rounded-2xl bg-neutral-50 px-4 py-3 text-sm text-neutral-600">
<LoaderCircle className="h-4 w-4 animate-spin" />
Checking existing MX records
</div>
)}
{mxRecordsExist === false && (
<div className="flex items-center gap-3 rounded-2xl bg-green-50 px-4 py-3 text-sm text-green-700">
<CheckCircle2 className="h-4 w-4" />
No existing MX records found
</div>
)}
{mxRecordsExist === true && (
<label className="flex cursor-pointer items-start gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-4 text-amber-900">
<Checkbox
checked={replaceMxRecords}
onChange={(event) => setReplaceMxRecords(event.target.checked)}
className="mt-1"
/>
<span>
<span className="flex items-center gap-2 text-sm font-medium">
<AlertTriangle className="h-4 w-4" />
Replace existing MX records
</span>
<span className="mt-1 block text-xs leading-5">
This deletes the current mail provider's MX records and replaces them with Cloudflare Email Routing. The previous provider will stop receiving mail.
</span>
</span>
</label>
)}
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2 relative">
@@ -369,27 +434,23 @@ export function RegisterClient() {
{error}
</p>
)}
{mxConflict && (
<div className="space-y-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-4 text-amber-900">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<p className="text-sm leading-6">
Existing MX records deliver mail to another provider. Continuing deletes those records and replaces them with Cloudflare Email Routing, so the previous provider will stop receiving mail.
</p>
</div>
</div>
{!mxChecking && mxRecordsExist === null && (
<Button
type="button"
variant="outline"
className="h-11 w-full rounded-full px-6 active:scale-[0.98]"
onClick={() => setMxCheckRevision((value) => value + 1)}
>
Check MX records again
</Button>
)}
<TurnstileField resetSignal={turnstileReset} />
<Button
type="submit"
name={mxConflict ? "replaceMxRecords" : undefined}
value={mxConflict ? "true" : undefined}
className="h-11 w-full rounded-full px-6 active:scale-[0.98] mt-8"
disabled={loading || hasAdminAccount === null || hasPrimaryDomain === null}
disabled={loading || mxChecking || mxRecordsExist === null || (mxRecordsExist && !replaceMxRecords) || hasAdminAccount === null || hasPrimaryDomain === null}
>
{loading
? mxConflict ? "Replacing MX records..." : "Creating..."
: mxConflict ? "Delete MX records and create account" : "Create account"}
{loading ? "Creating..." : "Create account"}
</Button>
</form>
)}
+5 -1
View File
@@ -27,8 +27,12 @@ export type DomainSetupResult = {
error?: string;
};
export type MxCheckResult = {
hasExistingMx?: boolean;
error?: string;
};
export type RegisterResult = {
token?: string;
redirect?: string;
error?: string;
code?: "MX_RECORDS_CONFLICT";
+18 -5
View File
@@ -1,6 +1,7 @@
import { persistAuthSession } from "@/lib/auth/client";
import { clearClientSessionToken } from "@/lib/auth/client";
import type {
DomainSetupResult,
MxCheckResult,
RegisterResult,
SetupPreparationResult,
SetupStatus,
@@ -34,6 +35,19 @@ export async function submitPrimaryDomain(hostname: string): Promise<{ ok: boole
};
}
export async function checkExistingMx(hostname: string): Promise<{ ok: boolean; data: MxCheckResult }> {
const res = await fetch("/api/setup/domain/mx", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hostname }),
});
return {
ok: res.ok,
data: (await res.json()) as MxCheckResult,
};
}
export async function submitRegistration(
form: FormData,
payload: { firstRun: boolean; domain: string; enableSending?: boolean; replaceMxRecords?: boolean },
@@ -61,8 +75,7 @@ export async function submitRegistration(
),
});
return {
ok: res.ok,
data: (await persistAuthSession(res)) as RegisterResult,
};
const data = (await res.json()) as RegisterResult;
if (res.ok) clearClientSessionToken();
return { ok: res.ok, data };
}
+4 -5
View File
@@ -5,7 +5,7 @@ import { getDb } from "@/db";
import { mailboxes, users } from "@/db/schema";
import { hashPassword } from "@/lib/auth/password";
import { hasAdminAccount } from "@/lib/auth/setup";
import { createSession, SESSION_COOKIE } from "@/lib/auth/session";
import { SESSION_COOKIE } from "@/lib/auth/session";
import { newId } from "@/lib/ids";
import { firstRunRegisterSchema } from "@/lib/validators";
import { addDomainForUser } from "@/lib/domains/service";
@@ -101,15 +101,14 @@ export async function POST(request: Request) {
);
}
const token = await createSession(env, userId);
const response = NextResponse.json({ ok: true, token, redirect: "/inbox" });
const response = NextResponse.json({ ok: true, redirect: "/login" });
response.headers.set("Cache-Control", "no-store");
response.cookies.set(SESSION_COOKIE, token, {
response.cookies.set(SESSION_COOKIE, "", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
maxAge: 0,
});
return response;
}
+41
View File
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { hasAdminAccount } from "@/lib/auth/setup";
import { getEnv } from "@/lib/cloudflare";
import { preflightDomain } from "@/lib/domains/preflight";
import { hasConflictingMxRecords } from "@/lib/domains/mx-records";
import { setupDomainSchema } from "@/lib/validators";
import { readJsonBody } from "@/lib/http/request";
import { RequestBodyTooLargeError } from "@/lib/http/errors";
export async function POST(request: Request) {
const env = getEnv();
if (await hasAdminAccount(env)) {
return NextResponse.json({ error: "Initial setup is already complete" }, { status: 403 });
}
let body: unknown;
try {
body = await readJsonBody(request, 16 * 1024);
} catch (error) {
const status = error instanceof RequestBodyTooLargeError ? 413 : 400;
return NextResponse.json({ error: "Invalid MX check request" }, { status });
}
const parsed = setupDomainSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
try {
const domain = await preflightDomain(env, parsed.data.hostname);
const hasExistingMx = await hasConflictingMxRecords(
env,
domain.zone.id,
domain.hostname,
);
return NextResponse.json({ hasExistingMx });
} catch (error) {
const message = error instanceof Error ? error.message : "MX record check failed";
return NextResponse.json({ error: message }, { status: 502 });
}
}
+16 -1
View File
@@ -2,6 +2,10 @@ import { createDnsRecord, deleteDnsRecord, listMxRecords } from "@/lib/cloudflar
import type { CfDnsRecord } from "@/lib/cloudflare-api.types";
import type { CloudflareDnsRecordCreate } from "@/lib/cloudflare-dns.types";
function isCloudflareEmailRoutingMx(record: CfDnsRecord): boolean {
return record.content?.toLowerCase().endsWith(".mx.cloudflare.net") ?? false;
}
function toCreateInput(record: CfDnsRecord): CloudflareDnsRecordCreate {
if (!record.name || !record.content || record.priority === undefined) {
throw new Error("Cloudflare returned an incomplete MX record");
@@ -25,7 +29,9 @@ export async function removeMxRecords(
hostname: string,
deleted: CfDnsRecord[],
): Promise<void> {
const records = await listMxRecords(env, zoneId, hostname);
const records = (await listMxRecords(env, zoneId, hostname)).filter(
(record) => !isCloudflareEmailRoutingMx(record),
);
for (const record of records) {
if (!record.id) throw new Error("Cloudflare returned an MX record without an id");
@@ -34,6 +40,15 @@ export async function removeMxRecords(
}
}
export async function hasConflictingMxRecords(
env: CloudflareEnv,
zoneId: string,
hostname: string,
): Promise<boolean> {
const records = await listMxRecords(env, zoneId, hostname);
return records.some((record) => !isCloudflareEmailRoutingMx(record));
}
export async function restoreMxRecords(
env: CloudflareEnv,
zoneId: string,