Skip to content

Commit ca8d4d4

Browse files
committed
fix: user creation initial role assignment and add SUPER_ADMIN role and logic
1 parent f12d5a7 commit ca8d4d4

File tree

24 files changed

+2209
-1532
lines changed

24 files changed

+2209
-1532
lines changed
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/*
2+
Warnings:
3+
4+
- You are about to drop the `Session` table. If the table is not empty, all the data it contains will be lost.
5+
- You are about to drop the `User` table. If the table is not empty, all the data it contains will be lost.
6+
7+
*/
8+
-- AlterEnum
9+
ALTER TYPE "UserRole" ADD VALUE 'SUPER_ADMIN';
10+
11+
-- DropForeignKey
12+
ALTER TABLE "public"."ApiKey" DROP CONSTRAINT "ApiKey_userId_fkey";
13+
14+
-- DropForeignKey
15+
ALTER TABLE "public"."Collection" DROP CONSTRAINT "Collection_createdById_fkey";
16+
17+
-- DropForeignKey
18+
ALTER TABLE "public"."CollectionAccess" DROP CONSTRAINT "CollectionAccess_userId_fkey";
19+
20+
-- DropForeignKey
21+
ALTER TABLE "public"."Session" DROP CONSTRAINT "Session_userId_fkey";
22+
23+
-- DropTable
24+
DROP TABLE "public"."Session";
25+
26+
-- DropTable
27+
DROP TABLE "public"."User";
28+
29+
-- CreateTable
30+
CREATE TABLE "user" (
31+
"id" TEXT NOT NULL,
32+
"username" TEXT NOT NULL,
33+
"email" TEXT,
34+
"displayName" TEXT,
35+
"avatar" TEXT,
36+
"role" "UserRole" NOT NULL DEFAULT 'USER',
37+
"passwordHash" TEXT,
38+
"pinHash" TEXT,
39+
"isPasswordless" BOOLEAN NOT NULL DEFAULT false,
40+
"isActive" BOOLEAN NOT NULL DEFAULT true,
41+
"isLocked" BOOLEAN NOT NULL DEFAULT false,
42+
"failedLoginAttempts" INTEGER NOT NULL DEFAULT 0,
43+
"lastLoginAt" TIMESTAMP(3),
44+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
45+
"updatedAt" TIMESTAMP(3) NOT NULL,
46+
"name" TEXT NOT NULL,
47+
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
48+
"image" TEXT,
49+
50+
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
51+
);
52+
53+
-- CreateTable
54+
CREATE TABLE "session" (
55+
"id" TEXT NOT NULL,
56+
"userId" TEXT NOT NULL,
57+
"token" TEXT NOT NULL,
58+
"ipAddress" TEXT,
59+
"userAgent" TEXT,
60+
"expiresAt" TIMESTAMP(3) NOT NULL,
61+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
62+
"lastActiveAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
63+
"updatedAt" TIMESTAMP(3) NOT NULL,
64+
65+
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
66+
);
67+
68+
-- CreateTable
69+
CREATE TABLE "account" (
70+
"id" TEXT NOT NULL,
71+
"accountId" TEXT NOT NULL,
72+
"providerId" TEXT NOT NULL,
73+
"userId" TEXT NOT NULL,
74+
"accessToken" TEXT,
75+
"refreshToken" TEXT,
76+
"idToken" TEXT,
77+
"accessTokenExpiresAt" TIMESTAMP(3),
78+
"refreshTokenExpiresAt" TIMESTAMP(3),
79+
"scope" TEXT,
80+
"password" TEXT,
81+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
82+
"updatedAt" TIMESTAMP(3) NOT NULL,
83+
84+
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
85+
);
86+
87+
-- CreateTable
88+
CREATE TABLE "verification" (
89+
"id" TEXT NOT NULL,
90+
"identifier" TEXT NOT NULL,
91+
"value" TEXT NOT NULL,
92+
"expiresAt" TIMESTAMP(3) NOT NULL,
93+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
94+
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
95+
96+
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
97+
);
98+
99+
-- CreateIndex
100+
CREATE UNIQUE INDEX "user_username_key" ON "user"("username");
101+
102+
-- CreateIndex
103+
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
104+
105+
-- CreateIndex
106+
CREATE INDEX "user_username_idx" ON "user"("username");
107+
108+
-- CreateIndex
109+
CREATE INDEX "user_email_idx" ON "user"("email");
110+
111+
-- CreateIndex
112+
CREATE INDEX "user_role_idx" ON "user"("role");
113+
114+
-- CreateIndex
115+
CREATE INDEX "user_isActive_idx" ON "user"("isActive");
116+
117+
-- CreateIndex
118+
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
119+
120+
-- CreateIndex
121+
CREATE INDEX "session_userId_idx" ON "session"("userId");
122+
123+
-- CreateIndex
124+
CREATE INDEX "session_token_idx" ON "session"("token");
125+
126+
-- CreateIndex
127+
CREATE INDEX "session_expiresAt_idx" ON "session"("expiresAt");
128+
129+
-- AddForeignKey
130+
ALTER TABLE "Collection" ADD CONSTRAINT "Collection_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
131+
132+
-- AddForeignKey
133+
ALTER TABLE "CollectionAccess" ADD CONSTRAINT "CollectionAccess_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
134+
135+
-- AddForeignKey
136+
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
137+
138+
-- AddForeignKey
139+
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
140+
141+
-- AddForeignKey
142+
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;

apps/api/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@ model ExternalId {
357357
// ────────────────────────────
358358

359359
enum UserRole {
360+
SUPER_ADMIN
360361
ADMIN
361362
USER
362363
GUEST

apps/api/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
import healthRouter from "./routes/health/health.module.js";
3737
import scanRouter from "./routes/scan/scan.module.js";
3838
import { authHandler } from "./lib/auth.js";
39+
import usersRouter from "./routes/users/users.module.js";
3940
import mediaRouter from "./routes/media/media.module.js";
4041
import moviesRouter from "./routes/movies/movies.module.js";
4142
import tvShowsRouter from "./routes/tv-shows/tv-shows.module.js";
@@ -224,9 +225,13 @@ app.use("/health", healthRouter);
224225

225226
const API_V1 = "/api/v1";
226227

227-
// Better-auth routes - handles all /api/auth/* endpoints
228+
// Better-auth routes - handles all authentication /api/auth/* endpoints
229+
// (register, login, logout, session management, etc.)
228230
app.all("/api/auth/*", authHandler);
229231

232+
// User management routes (admin only)
233+
app.use(`${API_V1}/users`, csrfProtection, usersRouter);
234+
230235
// Admin routes (requires admin role)
231236
app.use(`${API_V1}/admin`, adminRouter);
232237

apps/api/src/lib/auth.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const auth = betterAuth({
2727
role: {
2828
type: "string",
2929
required: false,
30+
// Note: Default is USER, but first user gets SUPER_ADMIN via DB trigger
3031
defaultValue: "USER",
3132
},
3233
},
@@ -41,17 +42,9 @@ export const auth = betterAuth({
4142
"http://localhost:3000",
4243
env.APP_URL || "http://localhost:3000",
4344
],
44-
onAfterSignUp: async (user: { user: { id: string } }) => {
45-
// Check if this is the first user - make them admin
46-
const userCount = await prisma.user.count();
47-
if (userCount === 1) {
48-
// This is the first user
49-
await prisma.user.update({
50-
where: { id: user.user.id },
51-
data: { role: "ADMIN" },
52-
});
53-
}
54-
},
45+
// NOTE: SUPER_ADMIN assignment is handled by Prisma middleware
46+
// See: src/lib/prisma.ts - the middleware intercepts user creation
47+
// and assigns SUPER_ADMIN role to the first user automatically
5548
});
5649

5750
export const authHandler = async (req: Request, res: Response) => {

apps/api/src/lib/prisma.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,19 +87,44 @@ function buildDatabaseUrl(): string {
8787
/**
8888
* Singleton instance - reused in development to prevent multiple connections
8989
*/
90-
export const prisma = global.__prisma ?? createPrismaClient();
90+
const basePrisma = global.__prisma ?? createPrismaClient();
9191

9292
if (env.NODE_ENV !== "production") {
93-
global.__prisma = prisma;
93+
global.__prisma = basePrisma;
9494
}
9595

96+
// ────────────────────────────────────────────────────────────────────────────
97+
// Prisma Extension: Auto-assign SUPER_ADMIN to first user
98+
// ────────────────────────────────────────────────────────────────────────────
99+
100+
export const prisma = basePrisma.$extends({
101+
query: {
102+
user: {
103+
async create({ args, query }) {
104+
// Check if this will be the first user
105+
const userCount = await basePrisma.user.count();
106+
107+
if (userCount === 0) {
108+
// This is the first user - set role to SUPER_ADMIN
109+
logger.info(
110+
"[Prisma Extension] Creating first user - assigning SUPER_ADMIN role"
111+
);
112+
args.data.role = "SUPER_ADMIN";
113+
}
114+
115+
return query(args);
116+
},
117+
},
118+
},
119+
});
120+
96121
/**
97122
* Gracefully disconnect from database
98123
* Should be called during application shutdown
99124
*/
100125
export async function disconnectPrisma(): Promise<void> {
101126
try {
102-
await prisma.$disconnect();
127+
await basePrisma.$disconnect();
103128
logger.info("Database disconnected successfully");
104129
} catch (error) {
105130
logger.error("Error disconnecting from database:", error);
@@ -121,8 +146,8 @@ export async function verifyDatabaseConnection(): Promise<void> {
121146
logger.info(
122147
`Attempting to connect to database (attempt ${attempt}/${maxRetries})...`
123148
);
124-
await prisma.$connect();
125-
await prisma.$queryRaw`SELECT 1`;
149+
await basePrisma.$connect();
150+
await basePrisma.$queryRaw`SELECT 1`;
126151
logger.info("✅ Database connection established successfully");
127152
return;
128153
} catch (error: unknown) {
@@ -175,7 +200,7 @@ export async function verifyDatabaseConnection(): Promise<void> {
175200
*/
176201
export async function checkDatabaseHealth(): Promise<boolean> {
177202
try {
178-
await prisma.$queryRaw`SELECT 1`;
203+
await basePrisma.$queryRaw`SELECT 1`;
179204
return true;
180205
} catch (error) {
181206
logger.error("Database health check failed:", error);

0 commit comments

Comments
 (0)