improve setup
This commit is contained in:
1 parent
76adbc8ebd
commit
dd67f90cf3
6 files changed
+166
-33
No files matched your search
@@ -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>
|
||||
)}
|
||||
|
||||
Vendored
+5
-1
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user