Fix Docker signup and document deploy, migrate, and rebuild flow.

Use FormData and server-side sign-in after register, trust proxy origins for reverse-proxy deploys, pass public URL build args in Docker, and clarify that git pull plus up -d --build is required to ship code and run pending migrations.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 01:06:10 -04:00
co-authored by Cursor
parent eb9548b832
commit 158b9416bf
11 changed files with 151 additions and 63 deletions
+21 -17
View File
@@ -2,31 +2,19 @@ import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "~/lib/auth";
import { env } from "~/env";
import { db } from "~/server/db";
import { accounts, users } from "~/server/db/schema";
const registerSchema = z
.object({
firstName: z.string().trim().optional(),
lastName: z.string().trim().optional(),
firstName: z.string().trim().min(1, "First name is required"),
lastName: z.string().trim().min(1, "Last name is required"),
name: z.string().trim().optional(),
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
})
.superRefine((data, ctx) => {
const hasSplitName =
Boolean(data.firstName?.length) && Boolean(data.lastName?.length);
const hasFullName = Boolean(data.name?.length);
if (!hasSplitName && !hasFullName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "First and last name are required",
path: ["firstName"],
});
}
})
.transform((data) => {
if (data.name?.length) {
const parts = data.name.trim().split(/\s+/);
@@ -41,8 +29,8 @@ const registerSchema = z
}
return {
firstName: data.firstName!.trim(),
lastName: data.lastName!.trim(),
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
password: data.password,
};
@@ -151,6 +139,22 @@ export async function POST(request: NextRequest) {
});
});
try {
await auth.api.signInEmail({
body: {
email: normalizedEmail,
password,
},
headers: request.headers,
});
} catch (signInError) {
console.error("Post-register sign-in failed:", signInError);
return NextResponse.json(
{ message: "User created successfully", signInRequired: true },
{ status: 201 },
);
}
return NextResponse.json(
{ message: "User created successfully" },
{ status: 201 },
+33 -27
View File
@@ -9,24 +9,27 @@ import { Label } from "~/components/ui/label";
import { toast } from "sonner";
import { Logo } from "~/components/branding/logo";
import { LegalAgreementNotice } from "~/components/legal/legal-links";
import { authClient } from "~/lib/auth-client";
import { Mail, Lock, ArrowRight, User } from "lucide-react";
function formatAuthError(message: string | undefined, fallback: string): string {
if (!message || message === "Required") {
return fallback;
}
return message;
}
function RegisterForm() {
const router = useRouter();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleRegister(e: React.FormEvent) {
async function handleRegister(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const trimmedFirstName = firstName.trim();
const trimmedLastName = lastName.trim();
const trimmedEmail = email.trim();
const formData = new FormData(e.currentTarget);
const trimmedFirstName = String(formData.get("firstName") ?? "").trim();
const trimmedLastName = String(formData.get("lastName") ?? "").trim();
const trimmedEmail = String(formData.get("email") ?? "").trim();
const password = String(formData.get("password") ?? "");
if (!trimmedFirstName || !trimmedLastName || !trimmedEmail) {
toast.error("Please enter your first name, last name, and email.");
@@ -52,19 +55,22 @@ function RegisterForm() {
}),
});
const data = (await res.json()) as { error?: string };
if (!res.ok) {
toast.error(data.error ?? "Registration failed");
let data: { error?: string; signInRequired?: boolean } = {};
try {
data = (await res.json()) as typeof data;
} catch {
toast.error("Registration failed. Please try again.");
return;
}
const { error: signInError } = await authClient.signIn.email({
email: trimmedEmail,
password,
});
if (!res.ok) {
toast.error(
formatAuthError(data.error, "Registration failed. Please check the form."),
);
return;
}
if (signInError) {
if (data.signInRequired) {
toast.success("Account created! Please sign in.");
router.push("/auth/signin");
return;
@@ -106,11 +112,11 @@ function RegisterForm() {
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="firstName"
name="firstName"
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
autoFocus
autoComplete="given-name"
className="h-10 pl-10"
placeholder="John"
/>
@@ -123,10 +129,10 @@ function RegisterForm() {
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="lastName"
name="lastName"
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
required
autoComplete="family-name"
className="h-10 pl-10"
placeholder="Doe"
/>
@@ -140,10 +146,10 @@ function RegisterForm() {
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
className="h-10 pl-10"
placeholder="you@example.com"
/>
@@ -156,11 +162,11 @@ function RegisterForm() {
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="password"
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="h-10 pl-10"
placeholder="••••••••"
/>
+5 -1
View File
@@ -36,7 +36,11 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
setLoading(false);
if (error) {
toast.error(error.message ?? "Invalid email or password");
toast.error(
error.message && error.message !== "Required"
? error.message
: "Invalid email or password",
);
} else {
toast.success("Signed in successfully!");
router.push(callbackUrl);
+1
View File
@@ -7,5 +7,6 @@ import { genericOAuthClient } from "better-auth/client/plugins";
* Auth client configuration
*/
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL,
plugins: [genericOAuthClient()],
});
+33 -9
View File
@@ -3,6 +3,7 @@ import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import { genericOAuth } from "better-auth/plugins";
import { envBoolean } from "~/lib/env-boolean";
import { db } from "~/server/db";
import * as schema from "~/server/db/schema";
@@ -11,7 +12,7 @@ const authentikEnabled = Boolean(
process.env.AUTHENTIK_CLIENT_ID &&
process.env.AUTHENTIK_CLIENT_SECRET,
);
const signupsDisabled = process.env.DISABLE_SIGNUPS === "true";
const signupsDisabled = envBoolean(process.env.DISABLE_SIGNUPS);
// Derive the authentik origin from the issuer URL so the OAuth callback is
// automatically trusted without needing a separate AUTHENTIK_ORIGIN env var.
@@ -20,9 +21,21 @@ const authentikOrigin =
? new URL(process.env.AUTHENTIK_ISSUER).origin
: null;
const staticTrustedOrigins = [
...(process.env.BETTER_AUTH_URL ? [process.env.BETTER_AUTH_URL] : []),
...(process.env.NEXT_PUBLIC_APP_URL ? [process.env.NEXT_PUBLIC_APP_URL] : []),
"beenvoice://",
"exp://",
...(authentikOrigin ? [authentikOrigin] : []),
...(process.env.AUTHENTIK_ORIGIN ? [process.env.AUTHENTIK_ORIGIN] : []),
];
export const auth = betterAuth({
baseURL: process.env.BETTER_AUTH_URL,
secret: process.env.AUTH_SECRET,
advanced: {
trustedProxyHeaders: true,
},
experimental: {
joins: true,
},
@@ -35,14 +48,25 @@ export const auth = betterAuth({
verification: schema.verificationTokens,
},
}),
trustedOrigins: [
...(process.env.BETTER_AUTH_URL ? [process.env.BETTER_AUTH_URL] : []),
...(process.env.NEXT_PUBLIC_APP_URL ? [process.env.NEXT_PUBLIC_APP_URL] : []),
"beenvoice://",
"exp://",
...(authentikOrigin ? [authentikOrigin] : []),
...(process.env.AUTHENTIK_ORIGIN ? [process.env.AUTHENTIK_ORIGIN] : []),
],
trustedOrigins: async (request) => {
const origins = [...staticTrustedOrigins];
if (!request) return origins;
const origin = request.headers.get("origin");
if (origin) origins.push(origin);
const forwardedHost = request.headers.get("x-forwarded-host");
const forwardedProto = request.headers.get("x-forwarded-proto") ?? "https";
if (forwardedHost) {
for (const host of forwardedHost.split(",")) {
const trimmed = host.trim();
if (trimmed) origins.push(`${forwardedProto}://${trimmed}`);
}
}
return origins;
},
...(authentikEnabled && {
accountLinking: {
enabled: true,
+6
View File
@@ -0,0 +1,6 @@
/** Parse env vars that Docker Compose passes as strings ("true" / "false"). */
export function envBoolean(value: string | undefined): boolean {
if (!value) return false;
const normalized = value.trim().toLowerCase();
return normalized === "true" || normalized === "1";
}
+2 -1
View File
@@ -1,10 +1,11 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { envBoolean } from "~/lib/env-boolean";
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname === "/auth/register" && process.env.DISABLE_SIGNUPS === "true") {
if (pathname === "/auth/register" && envBoolean(process.env.DISABLE_SIGNUPS)) {
const signInUrl = new URL("/auth/signin", request.url);
signInUrl.searchParams.set("signup", "disabled");
return NextResponse.redirect(signInUrl);