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. - - -
-
- - -
-
- - -
- {state?.error &&

{state.error}

} - -
-

- 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. - - -
-
- - -
-
- - -
-
- - -
- {state?.error &&

{state.error}

} - -
-

- 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. + + +
+
+ + +
+
+ + +
+ {state?.error &&

{state.error}

} + +
+ {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."} + + + +
+
+ + +
+
+ + +
+
+ + +
+ {state?.error &&

{state.error}

} + +
+

+ 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