Add bootstrap signup page for creating the first admin user
Deploy / deploy (push) Successful in 3m43s

The CLI create-user scripts hit environment issues on the VPS (missing
ts-node, missing node_modules on the host). A /signup page sidesteps
that entirely since it runs inside the already-working Next.js server.
It's a one-time-use setup form: the server action refuses to run once
any user already exists, so it's safe to leave in place, though it can
be deleted after the first account is created.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-08-18 16:26:50 +02:00
parent 29d5caa76a
commit 457469decd
4 changed files with 153 additions and 1 deletions
+45
View File
@@ -0,0 +1,45 @@
"use server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
export interface CreateFirstUserState {
error?: string;
success?: boolean;
}
// Bootstrap-only: only works while the users table is empty, so this can't
// be used to create accounts once the real admin user exists.
export async function createFirstUser(
_prevState: CreateFirstUserState,
formData: FormData
): Promise<CreateFirstUserState> {
const email = String(formData.get("email") || "").toLowerCase().trim();
const password = String(formData.get("password") || "");
const name = String(formData.get("name") || "").trim();
if (!email || !password) {
return { error: "Email and password are required." };
}
if (password.length < 8) {
return { error: "Password must be at least 8 characters long." };
}
const existingCount = await prisma.user.count();
if (existingCount > 0) {
return { error: "An account already exists. This setup page is disabled." };
}
const hashedPassword = await bcrypt.hash(password, 12);
await prisma.user.create({
data: {
email,
password: hashedPassword,
name: name || null,
role: "ADMIN",
},
});
return { success: true };
}
+45
View File
@@ -0,0 +1,45 @@
import { prisma } from "@/lib/prisma";
import { SignupForm } from "@/components/auth/signup-form";
import { Film } from "lucide-react";
export const dynamic = "force-dynamic";
export const metadata = {
title: "Set up account — xBid",
};
export default async function SignupPage() {
const userCount = await prisma.user.count();
return (
<div className="flex min-h-screen w-full items-center justify-center bg-background px-4">
<div className="w-full max-w-sm space-y-6">
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-500">
<Film className="h-5 w-5 text-black" />
</div>
<div>
<h1 className="text-lg font-semibold tracking-tight">
x<span className="text-amber-500">Bid</span>
</h1>
<p className="text-sm text-muted-foreground">
{userCount > 0 ? "Setup already complete" : "Create your account"}
</p>
</div>
</div>
{userCount > 0 ? (
<p className="text-center text-sm text-muted-foreground">
An account already exists, so this setup page is disabled.{" "}
<a href="/login" className="text-amber-500 underline">
Go to sign in
</a>
.
</p>
) : (
<SignupForm />
)}
</div>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { createFirstUser, type CreateFirstUserState } from "@/actions/setup";
const initialState: CreateFirstUserState = {};
export function SignupForm() {
const [state, action, pending] = useActionState(createFirstUser, initialState);
if (state.success) {
return (
<Card>
<CardContent className="space-y-4 text-center">
<p className="text-sm">
Account created. You can delete the signup page now — sign in at{" "}
<a href="/login" className="text-amber-500 underline">
/login
</a>
.
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardContent>
<form action={action} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="name">Name (optional)</Label>
<Input id="name" name="name" autoComplete="name" />
</div>
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" autoComplete="email" required />
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
minLength={8}
/>
</div>
{state.error && <p className="text-sm text-destructive">{state.error}</p>}
<Button type="submit" className="w-full" disabled={pending}>
{pending ? "Creating…" : "Create account"}
</Button>
</form>
</CardContent>
</Card>
);
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getToken } from "next-auth/jwt";
const PUBLIC_PATHS = ["/login"];
const PUBLIC_PATHS = ["/login", "/signup"];
export default async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;