Created different Authorization types
This commit is contained in:
parent
8639f9986c
commit
5a681bd444
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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't have an account?{" "}
|
|
||||||
<Link href="/signup" className="text-primary underline-offset-4 hover:underline">
|
|
||||||
Sign up
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
6
auth.ts
6
auth.ts
|
|
@ -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 };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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't have an account?{" "}
|
||||||
|
<Link href="/signup" className="text-primary underline-offset-4 hover:underline">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
);
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue