feat: role-based ACL + admin management UI
Backend: - Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers - Gate premium videos from unauthenticated users in videos query/resolver - Fix updateVideoPlay to verify ownership before updating - Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser - Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos - Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles - Add deleteComment mutation (owner or admin only) - Add AdminUserListType to GraphQL types - Fix featured filter on articles query Frontend: - Install marked for markdown rendering - Add /admin/* section with sidebar layout and admin-only guard - Admin users page: paginated table with search, role filter, inline role change, delete - Admin videos pages: list, create form, edit form with file upload and model assignment - Admin articles pages: list, create form, edit form with split-pane markdown editor - Add admin nav link in header (desktop + mobile) for admin users - Render article content through marked in magazine detail page - Add all admin GraphQL service functions to services.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
files,
|
||||
} from "../../db/schema/index";
|
||||
import { eq, and, lte, desc, inArray, count } from "drizzle-orm";
|
||||
import { requireRole, requireOwnerOrAdmin } from "../../lib/acl";
|
||||
|
||||
async function enrichVideo(db: any, video: any) {
|
||||
// Fetch models
|
||||
@@ -64,10 +65,13 @@ builder.queryField("videos", (t) =>
|
||||
limit: t.arg.int(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
// Unauthenticated users cannot see premium videos
|
||||
const premiumFilter = !ctx.currentUser ? eq(videos.premium, false) : undefined;
|
||||
|
||||
let query = ctx.db
|
||||
.select({ v: videos })
|
||||
.from(videos)
|
||||
.where(lte(videos.upload_date, new Date()))
|
||||
.where(and(lte(videos.upload_date, new Date()), premiumFilter))
|
||||
.orderBy(desc(videos.upload_date));
|
||||
|
||||
if (args.modelId) {
|
||||
@@ -84,6 +88,7 @@ builder.queryField("videos", (t) =>
|
||||
.where(
|
||||
and(
|
||||
lte(videos.upload_date, new Date()),
|
||||
premiumFilter,
|
||||
inArray(
|
||||
videos.id,
|
||||
videoIds.map((v: any) => v.video_id),
|
||||
@@ -97,7 +102,13 @@ builder.queryField("videos", (t) =>
|
||||
query = ctx.db
|
||||
.select({ v: videos })
|
||||
.from(videos)
|
||||
.where(and(lte(videos.upload_date, new Date()), eq(videos.featured, args.featured)))
|
||||
.where(
|
||||
and(
|
||||
lte(videos.upload_date, new Date()),
|
||||
premiumFilter,
|
||||
eq(videos.featured, args.featured),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(videos.upload_date));
|
||||
}
|
||||
|
||||
@@ -127,6 +138,11 @@ builder.queryField("video", (t) =>
|
||||
.limit(1);
|
||||
|
||||
if (!video[0]) return null;
|
||||
|
||||
if (video[0].premium && !ctx.currentUser) {
|
||||
throw new GraphQLError("Unauthorized");
|
||||
}
|
||||
|
||||
return enrichVideo(ctx.db, video[0]);
|
||||
},
|
||||
}),
|
||||
@@ -295,6 +311,19 @@ builder.mutationField("updateVideoPlay", (t) =>
|
||||
completed: t.arg.boolean({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const play = await ctx.db
|
||||
.select()
|
||||
.from(video_plays)
|
||||
.where(eq(video_plays.id, args.playId))
|
||||
.limit(1);
|
||||
|
||||
if (!play[0]) return false;
|
||||
|
||||
// If play belongs to a user, verify ownership
|
||||
if (play[0].user_id && (!ctx.currentUser || play[0].user_id !== ctx.currentUser.id)) {
|
||||
throw new GraphQLError("Forbidden");
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(video_plays)
|
||||
.set({
|
||||
@@ -396,3 +425,132 @@ builder.queryField("analytics", (t) =>
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// ─── Admin queries & mutations ────────────────────────────────────────────────
|
||||
|
||||
builder.queryField("adminListVideos", (t) =>
|
||||
t.field({
|
||||
type: [VideoType],
|
||||
resolve: async (_root, _args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
const rows = await ctx.db
|
||||
.select()
|
||||
.from(videos)
|
||||
.orderBy(desc(videos.upload_date));
|
||||
return Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v)));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("createVideo", (t) =>
|
||||
t.field({
|
||||
type: VideoType,
|
||||
args: {
|
||||
title: t.arg.string({ required: true }),
|
||||
slug: t.arg.string({ required: true }),
|
||||
description: t.arg.string(),
|
||||
imageId: t.arg.string(),
|
||||
movieId: t.arg.string(),
|
||||
tags: t.arg.stringList(),
|
||||
premium: t.arg.boolean(),
|
||||
featured: t.arg.boolean(),
|
||||
uploadDate: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
const inserted = await ctx.db
|
||||
.insert(videos)
|
||||
.values({
|
||||
title: args.title,
|
||||
slug: args.slug,
|
||||
description: args.description || null,
|
||||
image: args.imageId || null,
|
||||
movie: args.movieId || null,
|
||||
tags: args.tags || [],
|
||||
premium: args.premium ?? false,
|
||||
featured: args.featured ?? false,
|
||||
upload_date: args.uploadDate ? new Date(args.uploadDate) : new Date(),
|
||||
})
|
||||
.returning();
|
||||
return enrichVideo(ctx.db, inserted[0]);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("updateVideo", (t) =>
|
||||
t.field({
|
||||
type: VideoType,
|
||||
nullable: true,
|
||||
args: {
|
||||
id: t.arg.string({ required: true }),
|
||||
title: t.arg.string(),
|
||||
slug: t.arg.string(),
|
||||
description: t.arg.string(),
|
||||
imageId: t.arg.string(),
|
||||
movieId: t.arg.string(),
|
||||
tags: t.arg.stringList(),
|
||||
premium: t.arg.boolean(),
|
||||
featured: t.arg.boolean(),
|
||||
uploadDate: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (args.title !== undefined && args.title !== null) updates.title = args.title;
|
||||
if (args.slug !== undefined && args.slug !== null) updates.slug = args.slug;
|
||||
if (args.description !== undefined) updates.description = args.description;
|
||||
if (args.imageId !== undefined) updates.image = args.imageId;
|
||||
if (args.movieId !== undefined) updates.movie = args.movieId;
|
||||
if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags;
|
||||
if (args.premium !== undefined && args.premium !== null) updates.premium = args.premium;
|
||||
if (args.featured !== undefined && args.featured !== null) updates.featured = args.featured;
|
||||
if (args.uploadDate !== undefined && args.uploadDate !== null)
|
||||
updates.upload_date = new Date(args.uploadDate);
|
||||
|
||||
const updated = await ctx.db
|
||||
.update(videos)
|
||||
.set(updates as any)
|
||||
.where(eq(videos.id, args.id))
|
||||
.returning();
|
||||
if (!updated[0]) return null;
|
||||
return enrichVideo(ctx.db, updated[0]);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("deleteVideo", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
id: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
await ctx.db.delete(videos).where(eq(videos.id, args.id));
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("setVideoModels", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
videoId: t.arg.string({ required: true }),
|
||||
userIds: t.arg.stringList({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
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(
|
||||
args.userIds.map((userId) => ({
|
||||
video_id: args.videoId,
|
||||
user_id: userId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user