Add auto-close registration and admin settings page

First user to register automatically becomes admin and registration
closes. Admins can re-open registration from a new settings page.
Uses Better Auth admin plugin for role management and a new appSettings
table for the registration flag. Mobile tab bar now links to settings
instead of inline logout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 13:51:20 -05:00
co-authored by Claude Opus 4.6
parent c0ba717a93
commit db3a6cc3d4
13 changed files with 2076 additions and 20 deletions
+4 -1
View File
@@ -1,7 +1,10 @@
"use client";
import { adminClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient();
export const authClient = createAuthClient({
plugins: [adminClient()],
});
export const { signIn, signUp, signOut, useSession } = authClient;
+38
View File
@@ -1,7 +1,14 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { APIError } from "better-auth/api";
import { admin } from "better-auth/plugins";
import { v4 as uuid } from "uuid";
import { db } from "@/lib/db/client";
import {
getUserCount,
isRegistrationOpen,
setSetting,
} from "@/lib/services/settings";
export const auth = betterAuth({
database: drizzleAdapter(db, {
@@ -10,9 +17,40 @@ export const auth = betterAuth({
emailAndPassword: {
enabled: true,
},
plugins: [admin()],
advanced: {
database: {
generateId: () => uuid(),
},
},
databaseHooks: {
user: {
create: {
before: async (userData) => {
const userCount = await getUserCount();
if (userCount === 0) {
return {
data: {
...userData,
role: "admin",
},
};
}
const open = await isRegistrationOpen();
if (!open) {
throw new APIError("FORBIDDEN", {
message: "Registration is currently closed",
});
}
return { data: userData };
},
after: async () => {
const userCount = await getUserCount();
if (userCount === 1) {
await setSetting("registrationOpen", "false");
}
},
},
},
},
});
+12
View File
@@ -24,6 +24,10 @@ export const user = sqliteTable("user", {
.notNull()
.default(false),
image: text("image"),
role: text("role").default("user"),
banned: int("banned", { mode: "boolean" }).default(false),
banReason: text("banReason"),
banExpires: int("banExpires", { mode: "timestamp" }),
createdAt: int("createdAt", { mode: "timestamp" }).notNull(),
updatedAt: int("updatedAt", { mode: "timestamp" }).notNull(),
});
@@ -37,6 +41,7 @@ export const session = sqliteTable("session", {
expiresAt: int("expiresAt", { mode: "timestamp" }).notNull(),
ipAddress: text("ipAddress"),
userAgent: text("userAgent"),
impersonatedBy: text("impersonatedBy"),
createdAt: int("createdAt", { mode: "timestamp" }).notNull(),
updatedAt: int("updatedAt", { mode: "timestamp" }).notNull(),
});
@@ -277,3 +282,10 @@ export const titleRecommendations = sqliteTable(
),
],
);
// ─── App Settings ───────────────────────────────────────────────────
export const appSettings = sqliteTable("appSettings", {
key: text("key").primaryKey(),
value: text("value"),
});
+32
View File
@@ -0,0 +1,32 @@
import { count, eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { appSettings, user } from "@/lib/db/schema";
export async function getSetting(key: string): Promise<string | null> {
const row = await db
.select()
.from(appSettings)
.where(eq(appSettings.key, key))
.get();
return row?.value ?? null;
}
export async function setSetting(key: string, value: string): Promise<void> {
await db
.insert(appSettings)
.values({ key, value })
.onConflictDoUpdate({ target: appSettings.key, set: { value } });
}
export async function getUserCount(): Promise<number> {
const result = await db.select({ count: count() }).from(user).get();
return result?.count ?? 0;
}
export async function isRegistrationOpen(): Promise<boolean> {
const userCount = await getUserCount();
if (userCount === 0) return true;
const setting = await getSetting("registrationOpen");
return setting === "true";
}