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 { 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({
|
||||
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() {
|
|||
</div>
|
||||
|
||||
<AdminUserTable initialUsers={rows} currentUserId={session.user.id} />
|
||||
|
||||
<SignupModeSettings initialMode={signupMode} />
|
||||
</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";
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
return <LoginForm showSignupLink={signupMode !== SignupMode.CLOSED} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Create your account</CardTitle>
|
||||
<CardDescription>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>
|
||||
);
|
||||
// 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 <SignupForm mode={signupMode} />;
|
||||
}
|
||||
|
|
|
|||
6
auth.ts
6
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 };
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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 { 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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue