From 670c18bcb7abf198c5c558c4b615a7c33f432b48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Fri, 6 Mar 2026 16:14:00 +0100 Subject: [PATCH] feat: refactor role system to is_admin flag, add Badge component, fix native dialogs - Separate admin identity from role: viewer|model + is_admin boolean flag - DB migration 0001_is_admin: adds column, migrates former admin role users - Update ACL helpers, auth session, GraphQL types and all resolvers - Admin layout guard and header links check is_admin instead of role - Admin users table: show Admin badge next to name, remove admin from role select - Admin user edit page: is_admin checkbox toggle - Install shadcn Badge component; use in admin users table - Fix duplicate photo keys in adminGetUser resolver - Replace confirm() in /me recordings with Dialog component Co-Authored-By: Claude Sonnet 4.6 --- packages/backend/src/db/schema/users.ts | 1 + .../backend/src/graphql/resolvers/articles.ts | 10 ++-- .../backend/src/graphql/resolvers/auth.ts | 3 +- .../backend/src/graphql/resolvers/users.ts | 25 ++++++---- .../backend/src/graphql/resolvers/videos.ts | 12 ++--- packages/backend/src/graphql/types/index.ts | 3 ++ packages/backend/src/lib/acl.ts | 8 ++- packages/backend/src/lib/auth.ts | 3 +- .../backend/src/migrations/0001_is_admin.sql | 3 ++ .../backend/src/migrations/meta/_journal.json | 7 +++ .../src/lib/components/header/header.svelte | 4 +- .../src/lib/components/ui/badge/badge.svelte | 50 +++++++++++++++++++ .../src/lib/components/ui/badge/index.ts | 2 + packages/frontend/src/lib/services.ts | 7 +++ .../src/routes/admin/+layout.server.ts | 2 +- .../src/routes/admin/users/+page.svelte | 11 ++-- .../src/routes/admin/users/[id]/+page.svelte | 13 ++++- packages/frontend/src/routes/me/+page.svelte | 38 +++++++++++--- packages/types/src/index.ts | 3 +- 19 files changed, 162 insertions(+), 43 deletions(-) create mode 100644 packages/backend/src/migrations/0001_is_admin.sql create mode 100644 packages/frontend/src/lib/components/ui/badge/badge.svelte create mode 100644 packages/frontend/src/lib/components/ui/badge/index.ts diff --git a/packages/backend/src/db/schema/users.ts b/packages/backend/src/db/schema/users.ts index 2efe8d2..bd09042 100644 --- a/packages/backend/src/db/schema/users.ts +++ b/packages/backend/src/db/schema/users.ts @@ -29,6 +29,7 @@ export const users = pgTable( role: roleEnum("role").notNull().default("viewer"), avatar: text("avatar").references(() => files.id, { onDelete: "set null" }), banner: text("banner").references(() => files.id, { onDelete: "set null" }), + is_admin: boolean("is_admin").notNull().default(false), email_verified: boolean("email_verified").notNull().default(false), email_verify_token: text("email_verify_token"), password_reset_token: text("password_reset_token"), diff --git a/packages/backend/src/graphql/resolvers/articles.ts b/packages/backend/src/graphql/resolvers/articles.ts index b0f5a6b..beb456e 100644 --- a/packages/backend/src/graphql/resolvers/articles.ts +++ b/packages/backend/src/graphql/resolvers/articles.ts @@ -2,7 +2,7 @@ import { builder } from "../builder"; import { ArticleType } from "../types/index"; import { articles, users } from "../../db/schema/index"; import { eq, and, lte, desc } from "drizzle-orm"; -import { requireRole } from "../../lib/acl"; +import { requireAdmin } from "../../lib/acl"; async function enrichArticle(db: any, article: any) { let author = null; @@ -78,7 +78,7 @@ builder.queryField("adminListArticles", (t) => t.field({ type: [ArticleType], resolve: async (_root, _args, ctx) => { - requireRole(ctx, "admin"); + requireAdmin(ctx); const articleList = await ctx.db.select().from(articles).orderBy(desc(articles.publish_date)); return Promise.all(articleList.map((article: any) => enrichArticle(ctx.db, article))); }, @@ -100,7 +100,7 @@ builder.mutationField("createArticle", (t) => publishDate: t.arg.string(), }, resolve: async (_root, args, ctx) => { - requireRole(ctx, "admin"); + requireAdmin(ctx); const inserted = await ctx.db .insert(articles) .values({ @@ -138,7 +138,7 @@ builder.mutationField("updateArticle", (t) => publishDate: t.arg.string(), }, resolve: async (_root, args, ctx) => { - requireRole(ctx, "admin"); + requireAdmin(ctx); const updates: Record = { date_updated: new Date() }; if (args.title !== undefined && args.title !== null) updates.title = args.title; if (args.slug !== undefined && args.slug !== null) updates.slug = args.slug; @@ -169,7 +169,7 @@ builder.mutationField("deleteArticle", (t) => id: t.arg.string({ required: true }), }, resolve: async (_root, args, ctx) => { - requireRole(ctx, "admin"); + requireAdmin(ctx); await ctx.db.delete(articles).where(eq(articles.id, args.id)); return true; }, diff --git a/packages/backend/src/graphql/resolvers/auth.ts b/packages/backend/src/graphql/resolvers/auth.ts index 0cc2d12..1a818df 100644 --- a/packages/backend/src/graphql/resolvers/auth.ts +++ b/packages/backend/src/graphql/resolvers/auth.ts @@ -32,7 +32,8 @@ builder.mutationField("login", (t) => const sessionUser = { id: user[0].id, email: user[0].email, - role: user[0].role, + role: (user[0].role === "admin" ? "viewer" : user[0].role) as "model" | "viewer", + is_admin: user[0].is_admin, first_name: user[0].first_name, last_name: user[0].last_name, artist_name: user[0].artist_name, diff --git a/packages/backend/src/graphql/resolvers/users.ts b/packages/backend/src/graphql/resolvers/users.ts index 20b89b8..d2e1994 100644 --- a/packages/backend/src/graphql/resolvers/users.ts +++ b/packages/backend/src/graphql/resolvers/users.ts @@ -3,7 +3,7 @@ import { builder } from "../builder"; import { CurrentUserType, UserType, AdminUserListType, AdminUserDetailType } from "../types/index"; import { users, user_photos, files } from "../../db/schema/index"; import { eq, ilike, or, count, and } from "drizzle-orm"; -import { requireRole } from "../../lib/acl"; +import { requireAdmin } from "../../lib/acl"; builder.queryField("me", (t) => t.field({ @@ -86,7 +86,7 @@ builder.queryField("adminListUsers", (t) => offset: t.arg.int(), }, resolve: async (_root, args, ctx) => { - requireRole(ctx, "admin"); + requireAdmin(ctx); const limit = args.limit ?? 50; const offset = args.offset ?? 0; @@ -126,6 +126,7 @@ builder.mutationField("adminUpdateUser", (t) => args: { userId: t.arg.string({ required: true }), role: t.arg.string(), + isAdmin: t.arg.boolean(), firstName: t.arg.string(), lastName: t.arg.string(), artistName: t.arg.string(), @@ -133,10 +134,11 @@ builder.mutationField("adminUpdateUser", (t) => bannerId: t.arg.string(), }, resolve: async (_root, args, ctx) => { - requireRole(ctx, "admin"); + requireAdmin(ctx); const updates: Record = { date_updated: new Date() }; if (args.role !== undefined && args.role !== null) updates.role = args.role as any; + if (args.isAdmin !== undefined && args.isAdmin !== null) updates.is_admin = args.isAdmin; if (args.firstName !== undefined && args.firstName !== null) updates.first_name = args.firstName; if (args.lastName !== undefined && args.lastName !== null) updates.last_name = args.lastName; @@ -163,7 +165,7 @@ builder.mutationField("adminDeleteUser", (t) => userId: t.arg.string({ required: true }), }, resolve: async (_root, args, ctx) => { - requireRole(ctx, "admin"); + requireAdmin(ctx); if (args.userId === ctx.currentUser!.id) throw new GraphQLError("Cannot delete yourself"); await ctx.db.delete(users).where(eq(users.id, args.userId)); return true; @@ -179,7 +181,7 @@ builder.queryField("adminGetUser", (t) => userId: t.arg.string({ required: true }), }, resolve: async (_root, args, ctx) => { - requireRole(ctx, "admin"); + requireAdmin(ctx); const user = await ctx.db.select().from(users).where(eq(users.id, args.userId)).limit(1); if (!user[0]) return null; const photoRows = await ctx.db @@ -188,10 +190,11 @@ builder.queryField("adminGetUser", (t) => .leftJoin(files, eq(user_photos.file_id, files.id)) .where(eq(user_photos.user_id, args.userId)) .orderBy(user_photos.sort); - return { - ...user[0], - photos: photoRows.map((p: any) => ({ id: p.id, filename: p.filename })), - }; + const seen = new Set(); + const photos = photoRows + .filter((p: any) => p.id && !seen.has(p.id) && seen.add(p.id)) + .map((p: any) => ({ id: p.id, filename: p.filename })); + return { ...user[0], photos }; }, }), ); @@ -204,7 +207,7 @@ builder.mutationField("adminAddUserPhoto", (t) => fileId: t.arg.string({ required: true }), }, resolve: async (_root, args, ctx) => { - requireRole(ctx, "admin"); + requireAdmin(ctx); await ctx.db.insert(user_photos).values({ user_id: args.userId, file_id: args.fileId }); return true; }, @@ -219,7 +222,7 @@ builder.mutationField("adminRemoveUserPhoto", (t) => fileId: t.arg.string({ required: true }), }, resolve: async (_root, args, ctx) => { - requireRole(ctx, "admin"); + requireAdmin(ctx); await ctx.db .delete(user_photos) .where(and(eq(user_photos.user_id, args.userId), eq(user_photos.file_id, args.fileId))); diff --git a/packages/backend/src/graphql/resolvers/videos.ts b/packages/backend/src/graphql/resolvers/videos.ts index 11372b0..0db2eeb 100644 --- a/packages/backend/src/graphql/resolvers/videos.ts +++ b/packages/backend/src/graphql/resolvers/videos.ts @@ -15,7 +15,7 @@ import { files, } from "../../db/schema/index"; import { eq, and, lte, desc, inArray, count } from "drizzle-orm"; -import { requireRole } from "../../lib/acl"; +import { requireAdmin } from "../../lib/acl"; async function enrichVideo(db: any, video: any) { // Fetch models @@ -432,7 +432,7 @@ builder.queryField("adminListVideos", (t) => t.field({ type: [VideoType], resolve: async (_root, _args, ctx) => { - requireRole(ctx, "admin"); + requireAdmin(ctx); const rows = await ctx.db.select().from(videos).orderBy(desc(videos.upload_date)); return Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v))); }, @@ -454,7 +454,7 @@ builder.mutationField("createVideo", (t) => uploadDate: t.arg.string(), }, resolve: async (_root, args, ctx) => { - requireRole(ctx, "admin"); + requireAdmin(ctx); const inserted = await ctx.db .insert(videos) .values({ @@ -491,7 +491,7 @@ builder.mutationField("updateVideo", (t) => uploadDate: t.arg.string(), }, resolve: async (_root, args, ctx) => { - requireRole(ctx, "admin"); + requireAdmin(ctx); const updates: Record = {}; if (args.title !== undefined && args.title !== null) updates.title = args.title; if (args.slug !== undefined && args.slug !== null) updates.slug = args.slug; @@ -522,7 +522,7 @@ builder.mutationField("deleteVideo", (t) => id: t.arg.string({ required: true }), }, resolve: async (_root, args, ctx) => { - requireRole(ctx, "admin"); + requireAdmin(ctx); await ctx.db.delete(videos).where(eq(videos.id, args.id)); return true; }, @@ -537,7 +537,7 @@ builder.mutationField("setVideoModels", (t) => userIds: t.arg.stringList({ required: true }), }, resolve: async (_root, args, ctx) => { - requireRole(ctx, "admin"); + requireAdmin(ctx); await ctx.db.delete(video_models).where(eq(video_models.video_id, args.videoId)); if (args.userIds.length > 0) { await ctx.db.insert(video_models).values( diff --git a/packages/backend/src/graphql/types/index.ts b/packages/backend/src/graphql/types/index.ts index d3c5a1a..967e222 100644 --- a/packages/backend/src/graphql/types/index.ts +++ b/packages/backend/src/graphql/types/index.ts @@ -53,6 +53,7 @@ export const UserType = builder.objectRef("User").implement({ description: t.exposeString("description", { nullable: true }), tags: t.exposeStringList("tags", { nullable: true }), role: t.exposeString("role"), + is_admin: t.exposeBoolean("is_admin"), avatar: t.exposeString("avatar", { nullable: true }), banner: t.exposeString("banner", { nullable: true }), email_verified: t.exposeBoolean("email_verified"), @@ -72,6 +73,7 @@ export const CurrentUserType = builder.objectRef("CurrentUser").implement( description: t.exposeString("description", { nullable: true }), tags: t.exposeStringList("tags", { nullable: true }), role: t.exposeString("role"), + is_admin: t.exposeBoolean("is_admin"), avatar: t.exposeString("avatar", { nullable: true }), banner: t.exposeString("banner", { nullable: true }), email_verified: t.exposeBoolean("email_verified"), @@ -359,6 +361,7 @@ export const AdminUserDetailType = builder description: t.exposeString("description", { nullable: true }), tags: t.exposeStringList("tags", { nullable: true }), role: t.exposeString("role"), + is_admin: t.exposeBoolean("is_admin"), avatar: t.exposeString("avatar", { nullable: true }), banner: t.exposeString("banner", { nullable: true }), email_verified: t.exposeBoolean("email_verified"), diff --git a/packages/backend/src/lib/acl.ts b/packages/backend/src/lib/acl.ts index ff3766e..a1f5cb4 100644 --- a/packages/backend/src/lib/acl.ts +++ b/packages/backend/src/lib/acl.ts @@ -1,20 +1,18 @@ import { GraphQLError } from "graphql"; import type { Context } from "../graphql/builder"; -type UserRole = "viewer" | "model" | "admin"; - export function requireAuth(ctx: Context): void { if (!ctx.currentUser) throw new GraphQLError("Unauthorized"); } -export function requireRole(ctx: Context, ...roles: UserRole[]): void { +export function requireAdmin(ctx: Context): void { requireAuth(ctx); - if (!roles.includes(ctx.currentUser!.role)) throw new GraphQLError("Forbidden"); + if (!ctx.currentUser!.is_admin) throw new GraphQLError("Forbidden"); } export function requireOwnerOrAdmin(ctx: Context, ownerId: string): void { requireAuth(ctx); - if (ctx.currentUser!.id !== ownerId && ctx.currentUser!.role !== "admin") { + if (ctx.currentUser!.id !== ownerId && !ctx.currentUser!.is_admin) { throw new GraphQLError("Forbidden"); } } diff --git a/packages/backend/src/lib/auth.ts b/packages/backend/src/lib/auth.ts index 2782896..e847d50 100644 --- a/packages/backend/src/lib/auth.ts +++ b/packages/backend/src/lib/auth.ts @@ -3,7 +3,8 @@ import Redis from "ioredis"; export type SessionUser = { id: string; email: string; - role: "model" | "viewer" | "admin"; + role: "model" | "viewer"; + is_admin: boolean; first_name: string | null; last_name: string | null; artist_name: string | null; diff --git a/packages/backend/src/migrations/0001_is_admin.sql b/packages/backend/src/migrations/0001_is_admin.sql new file mode 100644 index 0000000..da4731b --- /dev/null +++ b/packages/backend/src/migrations/0001_is_admin.sql @@ -0,0 +1,3 @@ +ALTER TABLE "users" ADD COLUMN "is_admin" boolean NOT NULL DEFAULT false;--> statement-breakpoint +UPDATE "users" SET "is_admin" = true WHERE "role" = 'admin';--> statement-breakpoint +UPDATE "users" SET "role" = 'viewer' WHERE "role" = 'admin'; diff --git a/packages/backend/src/migrations/meta/_journal.json b/packages/backend/src/migrations/meta/_journal.json index 99831ca..0522221 100644 --- a/packages/backend/src/migrations/meta/_journal.json +++ b/packages/backend/src/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1772645674513, "tag": "0000_pale_hellion", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1772645674514, + "tag": "0001_is_admin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/frontend/src/lib/components/header/header.svelte b/packages/frontend/src/lib/components/header/header.svelte index e6eec5d..5972d9f 100644 --- a/packages/frontend/src/lib/components/header/header.svelte +++ b/packages/frontend/src/lib/components/header/header.svelte @@ -109,7 +109,7 @@ {$_("header.play")} - {#if authStatus.user?.role === "admin"} + {#if authStatus.user?.is_admin}

{data.user.artist_name || data.user.email}

-

{data.user.email} · {data.user.role}

+

{data.user.email} · {data.user.role}{data.user.is_admin ? " · admin" : ""}

@@ -151,6 +153,15 @@ + + +
+ + + + + {$_("me.recordings.delete_confirm")} + This cannot be undone. + + + + + + + diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 4b129db..7a67e1d 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -21,7 +21,8 @@ export interface User { slug: string | null; description: string | null; tags: string[] | null; - role: "model" | "viewer" | "admin"; + role: "model" | "viewer"; + is_admin: boolean; /** UUID of the avatar file */ avatar: string | null; /** UUID of the banner file */