Created different Authorization types

This commit is contained in:
Brian Fertig 2026-08-12 11:02:40 -06:00
parent 8639f9986c
commit 5a681bd444
13 changed files with 404 additions and 110 deletions

View File

@ -3,17 +3,22 @@ import { redirect } from "next/navigation";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { Role } from "@/lib/generated/prisma/enums"; import { Role } from "@/lib/generated/prisma/enums";
import { getSignupMode } from "@/lib/settings";
import { AdminUserTable, type AdminUserRow } from "@/components/admin/admin-user-table"; import { AdminUserTable, type AdminUserRow } from "@/components/admin/admin-user-table";
import { SignupModeSettings } from "@/components/admin/signup-mode-settings";
export default async function AdminPage() { export default async function AdminPage() {
const session = await auth(); const session = await auth();
if (!session?.user) redirect("/login"); if (!session?.user) redirect("/login");
if (session.user.role !== Role.ADMIN) redirect("/"); if (session.user.role !== Role.ADMIN) redirect("/");
const users = await prisma.user.findMany({ const [users, signupMode] = await Promise.all([
prisma.user.findMany({
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
select: { id: true, name: true, email: true, role: true, createdAt: true }, select: { id: true, name: true, email: true, role: true, createdAt: true },
}); }),
getSignupMode(),
]);
const rows: AdminUserRow[] = users.map((user) => ({ const rows: AdminUserRow[] = users.map((user) => ({
id: user.id, id: user.id,
@ -37,6 +42,8 @@ export default async function AdminPage() {
</div> </div>
<AdminUserTable initialUsers={rows} currentUserId={session.user.id} /> <AdminUserTable initialUsers={rows} currentUserId={session.user.id} />
<SignupModeSettings initialMode={signupMode} />
</div> </div>
); );
} }

View File

@ -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"; // Whether to show the "Sign up" link depends on live, admin-editable
import Link from "next/link"; // settings -- this can't be prerendered once at build time.
export const dynamic = "force-dynamic";
import { login } from "@/lib/actions/auth"; export default async function LoginPage() {
import { Button } from "@/components/ui/button"; const signupMode = await getSignupMode();
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export default function LoginPage() { return <LoginForm showSignupLink={signupMode !== SignupMode.CLOSED} />;
const [state, action, pending] = useActionState(login, undefined);
return (
<Card>
<CardHeader>
<CardTitle className="text-2xl">Welcome back</CardTitle>
<CardDescription>Log in to your board.</CardDescription>
</CardHeader>
<CardContent>
<form action={action} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" autoComplete="email" required />
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
/>
</div>
{state?.error && <p className="text-sm text-destructive">{state.error}</p>}
<Button type="submit" className="w-full" disabled={pending}>
{pending ? "Logging in…" : "Log in"}
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link href="/signup" className="text-primary underline-offset-4 hover:underline">
Sign up
</Link>
</p>
</CardContent>
</Card>
);
} }

View File

@ -1,56 +1,20 @@
"use client"; import { redirect } from "next/navigation";
import { useActionState } from "react"; import { getSignupMode } from "@/lib/settings";
import Link from "next/link"; import { SignupForm } from "@/components/auth/signup-form";
import { SignupMode } from "@/lib/generated/prisma/enums";
import { signup } from "@/lib/actions/auth"; // Whether sign-ups are open at all depends on live, admin-editable
import { Button } from "@/components/ui/button"; // settings -- this can't be prerendered once at build time.
import { Input } from "@/components/ui/input"; export const dynamic = "force-dynamic";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export default function SignupPage() { export default async function SignupPage() {
const [state, action, pending] = useActionState(signup, undefined); const signupMode = await getSignupMode();
return ( // Disables the URL, not just the messaging that links to it -- visiting
<Card> // /signup directly while closed bounces to /login instead of rendering
<CardHeader> // a dead form. signup() (the Server Action) refuses new accounts too.
<CardTitle className="text-2xl">Create your account</CardTitle> if (signupMode === SignupMode.CLOSED) redirect("/login");
<CardDescription>Start organizing in a minute.</CardDescription>
</CardHeader> return <SignupForm mode={signupMode} />;
<CardContent>
<form action={action} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" autoComplete="name" required />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" autoComplete="email" required />
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
minLength={8}
required
/>
</div>
{state?.error && <p className="text-sm text-destructive">{state.error}</p>}
<Button type="submit" className="w-full" disabled={pending}>
{pending ? "Creating account…" : "Sign up"}
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="text-primary underline-offset-4 hover:underline">
Log in
</Link>
</p>
</CardContent>
</Card>
);
} }

View File

@ -4,6 +4,8 @@ import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { LoginSchema } from "@/lib/validation/auth"; 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-only, JWT sessions, no database adapter: with a single
// Credentials provider and no OAuth, there's nothing for a DB-backed // 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); const passwordsMatch = await bcrypt.compare(password, user.passwordHash);
if (!passwordsMatch) return null; 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 }; return { id: user.id, email: user.email, name: user.name, role: user.role };
}, },
}), }),

View File

@ -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 (
<Card className="max-w-3xl">
<CardHeader>
<CardTitle>New sign-ups</CardTitle>
<CardDescription>Choose how the site handles people signing up for an account.</CardDescription>
</CardHeader>
<CardContent>
<RadioGroup value={current} onValueChange={handleChange}>
{OPTIONS.map((option) => (
<div
key={option.value}
className={`flex items-start gap-3 py-2 ${option.disabled ? "opacity-50" : ""}`}
>
<RadioGroupItem
value={option.value}
id={`signup-mode-${option.value}`}
disabled={option.disabled || pending}
className="mt-0.5"
/>
<div className="grid gap-1">
<Label htmlFor={`signup-mode-${option.value}`} className="gap-1.5">
{option.label}
{option.disabled && <Badge variant="outline">Coming soon</Badge>}
</Label>
<p className="text-sm text-muted-foreground">{option.description}</p>
</div>
</div>
))}
</RadioGroup>
</CardContent>
</Card>
);
}

View File

@ -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 (
<Card>
<CardHeader>
<CardTitle className="text-2xl">Welcome back</CardTitle>
<CardDescription>Log in to your board.</CardDescription>
</CardHeader>
<CardContent>
<form action={action} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" autoComplete="email" required />
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
/>
</div>
{state?.error && <p className="text-sm text-destructive">{state.error}</p>}
<Button type="submit" className="w-full" disabled={pending}>
{pending ? "Logging in…" : "Log in"}
</Button>
</form>
{showSignupLink && (
<p className="mt-4 text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link href="/signup" className="text-primary underline-offset-4 hover:underline">
Sign up
</Link>
</p>
)}
</CardContent>
</Card>
);
}

View File

@ -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 (
<Card>
<CardHeader>
<CardTitle className="text-2xl">Account created</CardTitle>
<CardDescription>
An administrator needs to approve your account before you can log in. Check back
soon.
</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="text-2xl">Create your account</CardTitle>
<CardDescription>
{mode === SignupMode.APPROVED
? "An administrator will need to approve your account before you can log in."
: "Start organizing in a minute."}
</CardDescription>
</CardHeader>
<CardContent>
<form action={action} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" autoComplete="name" required />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" autoComplete="email" required />
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
minLength={8}
required
/>
</div>
{state?.error && <p className="text-sm text-destructive">{state.error}</p>}
<Button type="submit" className="w-full" disabled={pending}>
{pending ? "Creating account…" : "Sign up"}
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="text-primary underline-offset-4 hover:underline">
Log in
</Link>
</p>
</CardContent>
</Card>
);
}

View File

@ -5,10 +5,19 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireAdmin } from "@/lib/auth-helpers"; 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 { EmailSchema, PasswordSchema } from "@/lib/validation/auth";
import { setSignupMode } from "@/lib/settings";
const ROLE_VALUES: readonly string[] = Object.values(Role); 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 -- // All of these are modeled as return values rather than thrown errors --
// Next.js redacts thrown Server Action error messages in production, and // Next.js redacts thrown Server Action error messages in production, and
@ -71,6 +80,17 @@ export async function updateUserPassword(
return {}; 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, * Deletes a user's account entirely, cascading through their categories,
* groups, and to-dos (`onDelete: Cascade` all the way down the schema). * groups, and to-dos (`onDelete: Cascade` all the way down the schema).

View File

@ -7,10 +7,15 @@ import { AuthError } from "next-auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { signIn, signOut } from "@/auth"; import { signIn, signOut } from "@/auth";
import { LoginSchema, SignupSchema } from "@/lib/validation/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 = { export type SignupState = {
error?: string; 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; } | undefined;
export type LoginState = { export type LoginState = {
@ -37,6 +42,9 @@ export async function login(
redirectTo: "/", redirectTo: "/",
}); });
} catch (error) { } catch (error) {
if (error instanceof PendingAccountSignin) {
return { error: "Your account is awaiting administrator approval." };
}
if (error instanceof AuthError) { if (error instanceof AuthError) {
return { error: "Incorrect email or password." }; return { error: "Incorrect email or password." };
} }
@ -60,19 +68,40 @@ export async function signup(
const { name, email, password } = parsed.data; 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 } }); const existing = await prisma.user.findUnique({ where: { email } });
if (existing) { if (existing) {
return { error: "An account with that email already exists." }; return { error: "An account with that email already exists." };
} }
const passwordHash = await bcrypt.hash(password, 12); const passwordHash = await bcrypt.hash(password, 12);
// The very first account on the whole site becomes the administrator; // The very first account on the whole site becomes the administrator
// everyone after that gets the default "USER" role. // 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 isFirstUser = (await prisma.user.count()) === 0;
const role = isFirstUser
? Role.ADMIN
: signupMode === SignupMode.OPEN
? Role.USER
: Role.PENDING;
await prisma.user.create({ 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 { try {
// Signs the user in and redirects to "/" on success. NextAuth throws a // Signs the user in and redirects to "/" on success. NextAuth throws a
// framework-handled redirect internally, so nothing after this runs. // framework-handled redirect internally, so nothing after this runs.

16
lib/auth-errors.ts Normal file
View File

@ -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";
}

29
lib/settings.ts Normal file
View File

@ -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<SignupMode> {
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<void> {
await prisma.siteSettings.upsert({
where: { id: SETTINGS_ID },
update: { signupMode: mode },
create: { id: SETTINGS_ID, signupMode: mode },
});
}

View File

@ -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")
);

View File

@ -16,14 +16,29 @@ enum Role {
PENDING 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 { model User {
id String @id @default(cuid()) id String @id @default(cuid())
email String @unique email String @unique
passwordHash String passwordHash String
name String? name String?
// The very first person to sign up becomes ADMIN; everyone after that // The very first person to sign up becomes ADMIN regardless of
// defaults to USER. PENDING exists as a role an admin can move someone // signupMode (the site needs at least one admin to bootstrap). Everyone
// into/out of later -- nothing assigns it automatically yet. // after that gets USER (signupMode OPEN) or PENDING (APPROVED/CONFIRMED),
// per SiteSettings.signupMode.
role Role @default(USER) role Role @default(USER)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -31,6 +46,15 @@ model User {
categories Category[] 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 { model Category {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String