diff --git a/app/(app)/admin/page.tsx b/app/(app)/admin/page.tsx
index b2f653d..3f0d5b1 100644
--- a/app/(app)/admin/page.tsx
+++ b/app/(app)/admin/page.tsx
@@ -3,17 +3,22 @@ import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { Role } from "@/lib/generated/prisma/enums";
+import { getSignupMode } from "@/lib/settings";
import { AdminUserTable, type AdminUserRow } from "@/components/admin/admin-user-table";
+import { SignupModeSettings } from "@/components/admin/signup-mode-settings";
export default async function AdminPage() {
const session = await auth();
if (!session?.user) redirect("/login");
if (session.user.role !== Role.ADMIN) redirect("/");
- const users = await prisma.user.findMany({
- orderBy: { createdAt: "asc" },
- select: { id: true, name: true, email: true, role: true, createdAt: true },
- });
+ const [users, signupMode] = await Promise.all([
+ prisma.user.findMany({
+ orderBy: { createdAt: "asc" },
+ select: { id: true, name: true, email: true, role: true, createdAt: true },
+ }),
+ getSignupMode(),
+ ]);
const rows: AdminUserRow[] = users.map((user) => ({
id: user.id,
@@ -37,6 +42,8 @@ export default async function AdminPage() {
+
+
);
}
diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx
index 17879dd..a205588 100644
--- a/app/(auth)/login/page.tsx
+++ b/app/(auth)/login/page.tsx
@@ -1,51 +1,13 @@
-"use client";
+import { getSignupMode } from "@/lib/settings";
+import { LoginForm } from "@/components/auth/login-form";
+import { SignupMode } from "@/lib/generated/prisma/enums";
-import { useActionState } from "react";
-import Link from "next/link";
+// Whether to show the "Sign up" link depends on live, admin-editable
+// settings -- this can't be prerendered once at build time.
+export const dynamic = "force-dynamic";
-import { login } from "@/lib/actions/auth";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+export default async function LoginPage() {
+ const signupMode = await getSignupMode();
-export default function LoginPage() {
- const [state, action, pending] = useActionState(login, undefined);
-
- return (
-
-
- Welcome back
- Log in to your board.
-
-
-
-
- Don't have an account?{" "}
-
- Sign up
-
-
-
-
- );
+ return ;
}
diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx
index c583a3d..ca24b3a 100644
--- a/app/(auth)/signup/page.tsx
+++ b/app/(auth)/signup/page.tsx
@@ -1,56 +1,20 @@
-"use client";
+import { redirect } from "next/navigation";
-import { useActionState } from "react";
-import Link from "next/link";
+import { getSignupMode } from "@/lib/settings";
+import { SignupForm } from "@/components/auth/signup-form";
+import { SignupMode } from "@/lib/generated/prisma/enums";
-import { signup } from "@/lib/actions/auth";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+// Whether sign-ups are open at all depends on live, admin-editable
+// settings -- this can't be prerendered once at build time.
+export const dynamic = "force-dynamic";
-export default function SignupPage() {
- const [state, action, pending] = useActionState(signup, undefined);
+export default async function SignupPage() {
+ const signupMode = await getSignupMode();
- return (
-
-
- Create your account
- Start organizing in a minute.
-
-
-
-
- Already have an account?{" "}
-
- Log in
-
-
-
-
- );
+ // Disables the URL, not just the messaging that links to it -- visiting
+ // /signup directly while closed bounces to /login instead of rendering
+ // a dead form. signup() (the Server Action) refuses new accounts too.
+ if (signupMode === SignupMode.CLOSED) redirect("/login");
+
+ return ;
}
diff --git a/auth.ts b/auth.ts
index 46b01b9..88853f2 100644
--- a/auth.ts
+++ b/auth.ts
@@ -4,6 +4,8 @@ import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
import { LoginSchema } from "@/lib/validation/auth";
+import { Role } from "@/lib/generated/prisma/enums";
+import { PendingAccountSignin } from "@/lib/auth-errors";
// Credentials-only, JWT sessions, no database adapter: with a single
// Credentials provider and no OAuth, there's nothing for a DB-backed
@@ -34,6 +36,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const passwordsMatch = await bcrypt.compare(password, user.passwordHash);
if (!passwordsMatch) return null;
+ // PENDING accounts are waiting on admin approval (or, eventually,
+ // email confirmation) and can't log in yet -- see lib/settings.ts.
+ if (user.role === Role.PENDING) throw new PendingAccountSignin();
+
return { id: user.id, email: user.email, name: user.name, role: user.role };
},
}),
diff --git a/components/admin/signup-mode-settings.tsx b/components/admin/signup-mode-settings.tsx
new file mode 100644
index 0000000..2ae0f7e
--- /dev/null
+++ b/components/admin/signup-mode-settings.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+import { useState } from "react";
+import { toast } from "sonner";
+
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Label } from "@/components/ui/label";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { updateSignupMode } from "@/lib/actions/admin";
+import { SignupMode } from "@/lib/generated/prisma/enums";
+
+const OPTIONS: {
+ value: SignupMode;
+ label: string;
+ description: string;
+ disabled?: boolean;
+}[] = [
+ {
+ value: SignupMode.OPEN,
+ label: "Open",
+ description: "Anyone can sign up and gets the User role right away.",
+ },
+ {
+ value: SignupMode.APPROVED,
+ label: "Approved",
+ description:
+ "Anyone can sign up, but starts Pending and can't log in until an admin changes their role to User or Administrator.",
+ },
+ {
+ value: SignupMode.CONFIRMED,
+ label: "Confirmed",
+ description: "Anyone can sign up, but starts Pending until they confirm their email.",
+ disabled: true,
+ },
+ {
+ value: SignupMode.CLOSED,
+ label: "Closed",
+ description:
+ "The site isn't accepting new accounts. The sign-up form and links are removed, and the sign-up action refuses new accounts.",
+ },
+];
+
+/** Lets an admin choose how the site handles new sign-ups. */
+export function SignupModeSettings({ initialMode }: { initialMode: SignupMode }) {
+ const [current, setCurrent] = useState(initialMode);
+ const [pending, setPending] = useState(false);
+
+ async function handleChange(next: string) {
+ if (next === current || pending) return;
+ const prev = current;
+ setCurrent(next as SignupMode);
+ setPending(true);
+ const result = await updateSignupMode(next);
+ setPending(false);
+ if (result?.error) {
+ setCurrent(prev);
+ toast.error(result.error);
+ }
+ }
+
+ return (
+
+
+ New sign-ups
+ Choose how the site handles people signing up for an account.
+
+
+
+ {OPTIONS.map((option) => (
+
+
+
+
+
{option.description}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx
new file mode 100644
index 0000000..0d603b1
--- /dev/null
+++ b/components/auth/login-form.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { useActionState } from "react";
+import Link from "next/link";
+
+import { login } from "@/lib/actions/auth";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+
+export function LoginForm({ showSignupLink }: { showSignupLink: boolean }) {
+ const [state, action, pending] = useActionState(login, undefined);
+
+ return (
+
+
+ Welcome back
+ Log in to your board.
+
+
+
+ {showSignupLink && (
+
+ Don't have an account?{" "}
+
+ Sign up
+
+
+ )}
+
+
+ );
+}
diff --git a/components/auth/signup-form.tsx b/components/auth/signup-form.tsx
new file mode 100644
index 0000000..774c465
--- /dev/null
+++ b/components/auth/signup-form.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useActionState } from "react";
+import Link from "next/link";
+
+import { signup } from "@/lib/actions/auth";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { SignupMode } from "@/lib/generated/prisma/enums";
+
+export function SignupForm({ mode }: { mode: SignupMode }) {
+ const [state, action, pending] = useActionState(signup, undefined);
+
+ if (state?.pending) {
+ return (
+
+
+ Account created
+
+ An administrator needs to approve your account before you can log in. Check back
+ soon.
+
+
+
+ );
+ }
+
+ return (
+
+
+ Create your account
+
+ {mode === SignupMode.APPROVED
+ ? "An administrator will need to approve your account before you can log in."
+ : "Start organizing in a minute."}
+
+
+
+
+
+ Already have an account?{" "}
+
+ Log in
+
+
+
+
+ );
+}
diff --git a/lib/actions/admin.ts b/lib/actions/admin.ts
index 3a930ce..e720971 100644
--- a/lib/actions/admin.ts
+++ b/lib/actions/admin.ts
@@ -5,10 +5,19 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { requireAdmin } from "@/lib/auth-helpers";
-import { Role } from "@/lib/generated/prisma/enums";
+import { Role, SignupMode } from "@/lib/generated/prisma/enums";
import { EmailSchema, PasswordSchema } from "@/lib/validation/auth";
+import { setSignupMode } from "@/lib/settings";
const ROLE_VALUES: readonly string[] = Object.values(Role);
+// CONFIRMED is a listed option (greyed out in the UI) but email
+// confirmation isn't built yet -- reject it here too, not just in the UI,
+// so it can't be turned on until that's actually implemented.
+const SETTABLE_SIGNUP_MODES: readonly string[] = [
+ SignupMode.OPEN,
+ SignupMode.APPROVED,
+ SignupMode.CLOSED,
+];
// All of these are modeled as return values rather than thrown errors --
// Next.js redacts thrown Server Action error messages in production, and
@@ -71,6 +80,17 @@ export async function updateUserPassword(
return {};
}
+export async function updateSignupMode(mode: string): Promise<{ error?: string }> {
+ await requireAdmin();
+ if (!SETTABLE_SIGNUP_MODES.includes(mode)) {
+ return { error: "That sign-up option isn't available yet." };
+ }
+
+ await setSignupMode(mode as SignupMode);
+ revalidatePath("/admin");
+ return {};
+}
+
/**
* Deletes a user's account entirely, cascading through their categories,
* groups, and to-dos (`onDelete: Cascade` all the way down the schema).
diff --git a/lib/actions/auth.ts b/lib/actions/auth.ts
index c6f4b19..d9cf475 100644
--- a/lib/actions/auth.ts
+++ b/lib/actions/auth.ts
@@ -7,10 +7,15 @@ import { AuthError } from "next-auth";
import { prisma } from "@/lib/db";
import { signIn, signOut } from "@/auth";
import { LoginSchema, SignupSchema } from "@/lib/validation/auth";
-import { Role } from "@/lib/generated/prisma/enums";
+import { Role, SignupMode } from "@/lib/generated/prisma/enums";
+import { getSignupMode } from "@/lib/settings";
+import { PendingAccountSignin } from "@/lib/auth-errors";
export type SignupState = {
error?: string;
+ // Set instead of `error` once a PENDING account is created successfully
+ // -- there's nothing to sign in yet, so the form has nothing to submit.
+ pending?: boolean;
} | undefined;
export type LoginState = {
@@ -37,6 +42,9 @@ export async function login(
redirectTo: "/",
});
} catch (error) {
+ if (error instanceof PendingAccountSignin) {
+ return { error: "Your account is awaiting administrator approval." };
+ }
if (error instanceof AuthError) {
return { error: "Incorrect email or password." };
}
@@ -60,19 +68,40 @@ export async function signup(
const { name, email, password } = parsed.data;
+ // Enforced here, not just hidden in the UI -- this is the actual
+ // back-stop against sign-ups while closed, since a Server Action can
+ // always be invoked directly regardless of what the page renders.
+ const signupMode = await getSignupMode();
+ if (signupMode === SignupMode.CLOSED) {
+ return { error: "Sign-ups are currently closed." };
+ }
+
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return { error: "An account with that email already exists." };
}
const passwordHash = await bcrypt.hash(password, 12);
- // The very first account on the whole site becomes the administrator;
- // everyone after that gets the default "USER" role.
+ // The very first account on the whole site becomes the administrator
+ // regardless of signupMode -- the site needs at least one admin to
+ // bootstrap. Everyone after that gets USER (mode OPEN) or PENDING
+ // (APPROVED, and CONFIRMED once email confirmation exists).
const isFirstUser = (await prisma.user.count()) === 0;
+ const role = isFirstUser
+ ? Role.ADMIN
+ : signupMode === SignupMode.OPEN
+ ? Role.USER
+ : Role.PENDING;
+
await prisma.user.create({
- data: { name, email, passwordHash, role: isFirstUser ? Role.ADMIN : Role.USER },
+ data: { name, email, passwordHash, role },
});
+ if (role === Role.PENDING) {
+ // Nothing to sign in to yet -- authorize() rejects PENDING accounts.
+ return { pending: true };
+ }
+
try {
// Signs the user in and redirects to "/" on success. NextAuth throws a
// framework-handled redirect internally, so nothing after this runs.
diff --git a/lib/auth-errors.ts b/lib/auth-errors.ts
new file mode 100644
index 0000000..65e9e86
--- /dev/null
+++ b/lib/auth-errors.ts
@@ -0,0 +1,16 @@
+import { CredentialsSignin } from "next-auth";
+
+/**
+ * Thrown from `authorize()` (in auth.ts) when a PENDING user's credentials
+ * are otherwise correct. Kept distinct from a plain `return null` (bad
+ * email/password) so `login()` (in lib/actions/auth.ts) can show a message
+ * that actually explains why the login failed, instead of "incorrect email
+ * or password."
+ *
+ * Auth.js rethrows errors from `authorize()` as-is when `signIn()` is
+ * called from a Server Action (see @auth/core's raw-mode handling), so
+ * `login()` can catch this by its `code` rather than parsing `message`.
+ */
+export class PendingAccountSignin extends CredentialsSignin {
+ code = "pending-account";
+}
diff --git a/lib/settings.ts b/lib/settings.ts
new file mode 100644
index 0000000..adabd03
--- /dev/null
+++ b/lib/settings.ts
@@ -0,0 +1,29 @@
+import "server-only";
+
+import { prisma } from "@/lib/db";
+import { SignupMode } from "@/lib/generated/prisma/enums";
+
+// Fixed id of the one-and-only SiteSettings row (see prisma/schema.prisma).
+const SETTINGS_ID = "singleton";
+
+/**
+ * Reads the site's sign-up mode. No row means the settings were never
+ * touched, which is the same as OPEN -- today's default behavior -- so
+ * this never creates a row just to read it.
+ */
+export async function getSignupMode(): Promise {
+ const settings = await prisma.siteSettings.findUnique({
+ where: { id: SETTINGS_ID },
+ select: { signupMode: true },
+ });
+ return settings?.signupMode ?? SignupMode.OPEN;
+}
+
+/** Creates the settings row on first write, updates it on every write after. */
+export async function setSignupMode(mode: SignupMode): Promise {
+ await prisma.siteSettings.upsert({
+ where: { id: SETTINGS_ID },
+ update: { signupMode: mode },
+ create: { id: SETTINGS_ID, signupMode: mode },
+ });
+}
diff --git a/prisma/migrations/20260812000000_add_site_settings/migration.sql b/prisma/migrations/20260812000000_add_site_settings/migration.sql
new file mode 100644
index 0000000..34d2652
--- /dev/null
+++ b/prisma/migrations/20260812000000_add_site_settings/migration.sql
@@ -0,0 +1,15 @@
+-- CreateEnum
+CREATE TYPE "SignupMode" AS ENUM ('OPEN', 'APPROVED', 'CONFIRMED', 'CLOSED');
+
+-- CreateTable
+-- Single-row settings table; the app always reads/writes the row at id
+-- 'singleton'. No row is created here -- absence of a row means "use
+-- defaults" (see getSignupMode in lib/settings.ts), so sites upgrading
+-- into this migration keep today's OPEN behavior without a backfill.
+CREATE TABLE "SiteSettings" (
+ "id" TEXT NOT NULL DEFAULT 'singleton',
+ "signupMode" "SignupMode" NOT NULL DEFAULT 'OPEN',
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "SiteSettings_pkey" PRIMARY KEY ("id")
+);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 52a67a4..8a576b1 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -16,14 +16,29 @@ enum Role {
PENDING
}
+// How the site handles new sign-ups. See SiteSettings.signupMode.
+enum SignupMode {
+ // Anyone can sign up and is immediately a USER. Today's behavior.
+ OPEN
+ // Anyone can sign up, but starts PENDING until an admin approves them
+ // (moves them to USER or ADMIN) on the Admin page.
+ APPROVED
+ // Anyone can sign up, but starts PENDING until they confirm their email.
+ // Not implemented yet -- the Admin page exposes this option greyed out.
+ CONFIRMED
+ // Sign-ups are turned off entirely: no form, no working sign-up action.
+ CLOSED
+}
+
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String?
- // The very first person to sign up becomes ADMIN; everyone after that
- // defaults to USER. PENDING exists as a role an admin can move someone
- // into/out of later -- nothing assigns it automatically yet.
+ // The very first person to sign up becomes ADMIN regardless of
+ // signupMode (the site needs at least one admin to bootstrap). Everyone
+ // after that gets USER (signupMode OPEN) or PENDING (APPROVED/CONFIRMED),
+ // per SiteSettings.signupMode.
role Role @default(USER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -31,6 +46,15 @@ model User {
categories Category[]
}
+// Single-row table of site-wide settings. Always has exactly one row, at
+// the fixed id below -- read with `findFirst` (falling back to defaults
+// when the row doesn't exist yet) and written with `upsert`.
+model SiteSettings {
+ id String @id @default("singleton")
+ signupMode SignupMode @default(OPEN)
+ updatedAt DateTime @updatedAt
+}
+
model Category {
id String @id @default(cuid())
name String