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:
@@ -990,6 +990,470 @@ export async function updateVideoPlay(
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Delete comment ──────────────────────────────────────────────────────────
|
||||
|
||||
const DELETE_COMMENT_MUTATION = gql`
|
||||
mutation DeleteComment($id: Int!) {
|
||||
deleteComment(id: $id)
|
||||
}
|
||||
`;
|
||||
|
||||
export async function deleteComment(id: number) {
|
||||
return loggedApiCall(
|
||||
"deleteComment",
|
||||
async () => {
|
||||
await getGraphQLClient().request(DELETE_COMMENT_MUTATION, { id });
|
||||
},
|
||||
{ id },
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Admin: Users ─────────────────────────────────────────────────────────────
|
||||
|
||||
const ADMIN_LIST_USERS_QUERY = gql`
|
||||
query AdminListUsers($role: String, $search: String, $limit: Int, $offset: Int) {
|
||||
adminListUsers(role: $role, search: $search, limit: $limit, offset: $offset) {
|
||||
total
|
||||
items {
|
||||
id
|
||||
email
|
||||
first_name
|
||||
last_name
|
||||
artist_name
|
||||
slug
|
||||
role
|
||||
avatar
|
||||
email_verified
|
||||
date_created
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export async function adminListUsers(
|
||||
opts: { role?: string; search?: string; limit?: number; offset?: number } = {},
|
||||
fetchFn?: typeof globalThis.fetch,
|
||||
) {
|
||||
return loggedApiCall(
|
||||
"adminListUsers",
|
||||
async () => {
|
||||
const data = await getGraphQLClient(fetchFn).request<{
|
||||
adminListUsers: { total: number; items: User[] };
|
||||
}>(ADMIN_LIST_USERS_QUERY, opts);
|
||||
return data.adminListUsers;
|
||||
},
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
const ADMIN_UPDATE_USER_MUTATION = gql`
|
||||
mutation AdminUpdateUser(
|
||||
$userId: String!
|
||||
$role: String
|
||||
$firstName: String
|
||||
$lastName: String
|
||||
$artistName: String
|
||||
) {
|
||||
adminUpdateUser(
|
||||
userId: $userId
|
||||
role: $role
|
||||
firstName: $firstName
|
||||
lastName: $lastName
|
||||
artistName: $artistName
|
||||
) {
|
||||
id
|
||||
email
|
||||
first_name
|
||||
last_name
|
||||
artist_name
|
||||
role
|
||||
avatar
|
||||
date_created
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export async function adminUpdateUser(input: {
|
||||
userId: string;
|
||||
role?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
artistName?: string;
|
||||
}) {
|
||||
return loggedApiCall(
|
||||
"adminUpdateUser",
|
||||
async () => {
|
||||
const data = await getGraphQLClient().request<{ adminUpdateUser: User | null }>(
|
||||
ADMIN_UPDATE_USER_MUTATION,
|
||||
input,
|
||||
);
|
||||
return data.adminUpdateUser;
|
||||
},
|
||||
{ userId: input.userId },
|
||||
);
|
||||
}
|
||||
|
||||
const ADMIN_DELETE_USER_MUTATION = gql`
|
||||
mutation AdminDeleteUser($userId: String!) {
|
||||
adminDeleteUser(userId: $userId)
|
||||
}
|
||||
`;
|
||||
|
||||
export async function adminDeleteUser(userId: string) {
|
||||
return loggedApiCall(
|
||||
"adminDeleteUser",
|
||||
async () => {
|
||||
await getGraphQLClient().request(ADMIN_DELETE_USER_MUTATION, { userId });
|
||||
},
|
||||
{ userId },
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Admin: Videos ────────────────────────────────────────────────────────────
|
||||
|
||||
const ADMIN_LIST_VIDEOS_QUERY = gql`
|
||||
query AdminListVideos {
|
||||
adminListVideos {
|
||||
id
|
||||
slug
|
||||
title
|
||||
description
|
||||
image
|
||||
movie
|
||||
tags
|
||||
upload_date
|
||||
premium
|
||||
featured
|
||||
likes_count
|
||||
plays_count
|
||||
models {
|
||||
id
|
||||
artist_name
|
||||
slug
|
||||
avatar
|
||||
}
|
||||
movie_file {
|
||||
id
|
||||
filename
|
||||
mime_type
|
||||
duration
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export async function adminListVideos(fetchFn?: typeof globalThis.fetch) {
|
||||
return loggedApiCall("adminListVideos", async () => {
|
||||
const data = await getGraphQLClient(fetchFn).request<{ adminListVideos: Video[] }>(
|
||||
ADMIN_LIST_VIDEOS_QUERY,
|
||||
);
|
||||
return data.adminListVideos;
|
||||
});
|
||||
}
|
||||
|
||||
const CREATE_VIDEO_MUTATION = gql`
|
||||
mutation CreateVideo(
|
||||
$title: String!
|
||||
$slug: String!
|
||||
$description: String
|
||||
$imageId: String
|
||||
$movieId: String
|
||||
$tags: [String!]
|
||||
$premium: Boolean
|
||||
$featured: Boolean
|
||||
$uploadDate: String
|
||||
) {
|
||||
createVideo(
|
||||
title: $title
|
||||
slug: $slug
|
||||
description: $description
|
||||
imageId: $imageId
|
||||
movieId: $movieId
|
||||
tags: $tags
|
||||
premium: $premium
|
||||
featured: $featured
|
||||
uploadDate: $uploadDate
|
||||
) {
|
||||
id
|
||||
slug
|
||||
title
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export async function createVideo(input: {
|
||||
title: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
imageId?: string;
|
||||
movieId?: string;
|
||||
tags?: string[];
|
||||
premium?: boolean;
|
||||
featured?: boolean;
|
||||
uploadDate?: string;
|
||||
}) {
|
||||
return loggedApiCall(
|
||||
"createVideo",
|
||||
async () => {
|
||||
const data = await getGraphQLClient().request<{ createVideo: Video }>(
|
||||
CREATE_VIDEO_MUTATION,
|
||||
input,
|
||||
);
|
||||
return data.createVideo;
|
||||
},
|
||||
{ title: input.title },
|
||||
);
|
||||
}
|
||||
|
||||
const UPDATE_VIDEO_MUTATION = gql`
|
||||
mutation UpdateVideo(
|
||||
$id: String!
|
||||
$title: String
|
||||
$slug: String
|
||||
$description: String
|
||||
$imageId: String
|
||||
$movieId: String
|
||||
$tags: [String!]
|
||||
$premium: Boolean
|
||||
$featured: Boolean
|
||||
$uploadDate: String
|
||||
) {
|
||||
updateVideo(
|
||||
id: $id
|
||||
title: $title
|
||||
slug: $slug
|
||||
description: $description
|
||||
imageId: $imageId
|
||||
movieId: $movieId
|
||||
tags: $tags
|
||||
premium: $premium
|
||||
featured: $featured
|
||||
uploadDate: $uploadDate
|
||||
) {
|
||||
id
|
||||
slug
|
||||
title
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export async function updateVideo(input: {
|
||||
id: string;
|
||||
title?: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
imageId?: string;
|
||||
movieId?: string;
|
||||
tags?: string[];
|
||||
premium?: boolean;
|
||||
featured?: boolean;
|
||||
uploadDate?: string;
|
||||
}) {
|
||||
return loggedApiCall(
|
||||
"updateVideo",
|
||||
async () => {
|
||||
const data = await getGraphQLClient().request<{ updateVideo: Video | null }>(
|
||||
UPDATE_VIDEO_MUTATION,
|
||||
input,
|
||||
);
|
||||
return data.updateVideo;
|
||||
},
|
||||
{ id: input.id },
|
||||
);
|
||||
}
|
||||
|
||||
const DELETE_VIDEO_MUTATION = gql`
|
||||
mutation DeleteVideo($id: String!) {
|
||||
deleteVideo(id: $id)
|
||||
}
|
||||
`;
|
||||
|
||||
export async function deleteVideo(id: string) {
|
||||
return loggedApiCall(
|
||||
"deleteVideo",
|
||||
async () => {
|
||||
await getGraphQLClient().request(DELETE_VIDEO_MUTATION, { id });
|
||||
},
|
||||
{ id },
|
||||
);
|
||||
}
|
||||
|
||||
const SET_VIDEO_MODELS_MUTATION = gql`
|
||||
mutation SetVideoModels($videoId: String!, $userIds: [String!]!) {
|
||||
setVideoModels(videoId: $videoId, userIds: $userIds)
|
||||
}
|
||||
`;
|
||||
|
||||
export async function setVideoModels(videoId: string, userIds: string[]) {
|
||||
return loggedApiCall(
|
||||
"setVideoModels",
|
||||
async () => {
|
||||
await getGraphQLClient().request(SET_VIDEO_MODELS_MUTATION, { videoId, userIds });
|
||||
},
|
||||
{ videoId, count: userIds.length },
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Admin: Articles ──────────────────────────────────────────────────────────
|
||||
|
||||
const ADMIN_LIST_ARTICLES_QUERY = gql`
|
||||
query AdminListArticles {
|
||||
adminListArticles {
|
||||
id
|
||||
slug
|
||||
title
|
||||
excerpt
|
||||
image
|
||||
tags
|
||||
publish_date
|
||||
category
|
||||
featured
|
||||
content
|
||||
author {
|
||||
first_name
|
||||
last_name
|
||||
avatar
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export async function adminListArticles(fetchFn?: typeof globalThis.fetch) {
|
||||
return loggedApiCall("adminListArticles", async () => {
|
||||
const data = await getGraphQLClient(fetchFn).request<{ adminListArticles: Article[] }>(
|
||||
ADMIN_LIST_ARTICLES_QUERY,
|
||||
);
|
||||
return data.adminListArticles;
|
||||
});
|
||||
}
|
||||
|
||||
const CREATE_ARTICLE_MUTATION = gql`
|
||||
mutation CreateArticle(
|
||||
$title: String!
|
||||
$slug: String!
|
||||
$excerpt: String
|
||||
$content: String
|
||||
$imageId: String
|
||||
$tags: [String!]
|
||||
$category: String
|
||||
$featured: Boolean
|
||||
$publishDate: String
|
||||
) {
|
||||
createArticle(
|
||||
title: $title
|
||||
slug: $slug
|
||||
excerpt: $excerpt
|
||||
content: $content
|
||||
imageId: $imageId
|
||||
tags: $tags
|
||||
category: $category
|
||||
featured: $featured
|
||||
publishDate: $publishDate
|
||||
) {
|
||||
id
|
||||
slug
|
||||
title
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export async function createArticle(input: {
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt?: string;
|
||||
content?: string;
|
||||
imageId?: string;
|
||||
tags?: string[];
|
||||
category?: string;
|
||||
featured?: boolean;
|
||||
publishDate?: string;
|
||||
}) {
|
||||
return loggedApiCall(
|
||||
"createArticle",
|
||||
async () => {
|
||||
const data = await getGraphQLClient().request<{ createArticle: Article }>(
|
||||
CREATE_ARTICLE_MUTATION,
|
||||
input,
|
||||
);
|
||||
return data.createArticle;
|
||||
},
|
||||
{ title: input.title },
|
||||
);
|
||||
}
|
||||
|
||||
const UPDATE_ARTICLE_MUTATION = gql`
|
||||
mutation UpdateArticle(
|
||||
$id: String!
|
||||
$title: String
|
||||
$slug: String
|
||||
$excerpt: String
|
||||
$content: String
|
||||
$imageId: String
|
||||
$tags: [String!]
|
||||
$category: String
|
||||
$featured: Boolean
|
||||
$publishDate: String
|
||||
) {
|
||||
updateArticle(
|
||||
id: $id
|
||||
title: $title
|
||||
slug: $slug
|
||||
excerpt: $excerpt
|
||||
content: $content
|
||||
imageId: $imageId
|
||||
tags: $tags
|
||||
category: $category
|
||||
featured: $featured
|
||||
publishDate: $publishDate
|
||||
) {
|
||||
id
|
||||
slug
|
||||
title
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export async function updateArticle(input: {
|
||||
id: string;
|
||||
title?: string;
|
||||
slug?: string;
|
||||
excerpt?: string;
|
||||
content?: string;
|
||||
imageId?: string;
|
||||
tags?: string[];
|
||||
category?: string;
|
||||
featured?: boolean;
|
||||
publishDate?: string;
|
||||
}) {
|
||||
return loggedApiCall(
|
||||
"updateArticle",
|
||||
async () => {
|
||||
const data = await getGraphQLClient().request<{ updateArticle: Article | null }>(
|
||||
UPDATE_ARTICLE_MUTATION,
|
||||
input,
|
||||
);
|
||||
return data.updateArticle;
|
||||
},
|
||||
{ id: input.id },
|
||||
);
|
||||
}
|
||||
|
||||
const DELETE_ARTICLE_MUTATION = gql`
|
||||
mutation DeleteArticle($id: String!) {
|
||||
deleteArticle(id: $id)
|
||||
}
|
||||
`;
|
||||
|
||||
export async function deleteArticle(id: string) {
|
||||
return loggedApiCall(
|
||||
"deleteArticle",
|
||||
async () => {
|
||||
await getGraphQLClient().request(DELETE_ARTICLE_MUTATION, { id });
|
||||
},
|
||||
{ id },
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Analytics ───────────────────────────────────────────────────────────────
|
||||
|
||||
const ANALYTICS_QUERY = gql`
|
||||
|
||||
Reference in New Issue
Block a user