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:
2026-03-06 12:31:33 +01:00
parent b200498a10
commit c1770ab9c9
28 changed files with 2311 additions and 43 deletions

View File

@@ -109,6 +109,22 @@
<span class="sr-only">{$_("header.play")}</span>
</Button>
{#if authStatus.user?.role === "admin"}
<Button
variant="link"
size="icon"
class={`h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: "/admin" }) ? "text-foreground" : "hover:text-foreground"}`}
href="/admin/users"
title="Admin"
>
<span class="icon-[ri--settings-3-line] h-4 w-4"></span>
<span
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: "/admin" }) ? "w-full" : "group-hover:w-full"}`}
></span>
<span class="sr-only">Admin</span>
</Button>
{/if}
<Separator orientation="vertical" class="mx-1 h-6 bg-border/50" />
<LogoutButton
@@ -265,6 +281,27 @@
</div>
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
</a>
{#if authStatus.user?.role === "admin"}
<a
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/admin" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}
href="/admin/users"
onclick={closeMenu}
>
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/60 group-hover:bg-primary/10 transition-colors"
>
<span
class="icon-[ri--settings-3-line] h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors"
></span>
</div>
<div class="flex flex-1 flex-col gap-0.5">
<span class="text-sm font-medium text-foreground">Admin</span>
<span class="text-xs text-muted-foreground">Manage content</span>
</div>
<span class="icon-[ri--arrow-right-s-line] h-4 w-4 text-muted-foreground"></span>
</a>
{/if}
{:else}
<a
class={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all duration-200 group hover:border-primary/30 hover:bg-primary/5 ${isActiveLink({ href: "/login" }) ? "border-primary/40 bg-primary/8" : "border-border/40 bg-card/50"}`}

View File

@@ -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`