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:
@@ -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 },
|
||||
|
||||
@@ -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="••••••••"
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user