Files
sexy/packages/frontend/src/routes/admin/users/+page.svelte
Sebastian Krüger c1770ab9c9 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>
2026-03-06 12:31:33 +01:00

243 lines
7.9 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { goto, invalidateAll } from "$app/navigation";
import { page } from "$app/state";
import { toast } from "svelte-sonner";
import { adminUpdateUser, adminDeleteUser } from "$lib/services";
import { getAssetUrl } from "$lib/api";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import * as Dialog from "$lib/components/ui/dialog";
import type { User } from "$lib/types";
const { data } = $props();
let searchValue = $state(data.search ?? "");
let searchTimeout: ReturnType<typeof setTimeout>;
let deleteTarget: User | null = $state(null);
let deleteOpen = $state(false);
let deleting = $state(false);
let updatingId = $state<string | null>(null);
const currentUserId = page.data.authStatus?.user?.id;
const roles = ["", "viewer", "model", "admin"] as const;
function debounceSearch(value: string) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const params = new URLSearchParams(page.url.searchParams);
if (value) params.set("search", value);
else params.delete("search");
params.delete("offset");
goto(`?${params.toString()}`, { keepFocus: true });
}, 300);
}
function setRole(role: string) {
const params = new URLSearchParams(page.url.searchParams);
if (role) params.set("role", role);
else params.delete("role");
params.delete("offset");
goto(`?${params.toString()}`);
}
async function changeUserRole(user: User, newRole: string) {
updatingId = user.id;
try {
await adminUpdateUser({ userId: user.id, role: newRole });
toast.success(`Role updated to ${newRole}`);
await invalidateAll();
} catch {
toast.error("Failed to update role");
} finally {
updatingId = null;
}
}
function confirmDelete(user: User) {
deleteTarget = user;
deleteOpen = true;
}
async function handleDelete() {
if (!deleteTarget) return;
deleting = true;
try {
await adminDeleteUser(deleteTarget.id);
toast.success("User deleted");
deleteOpen = false;
deleteTarget = null;
await invalidateAll();
} catch {
toast.error("Failed to delete user");
} finally {
deleting = false;
}
}
function formatDate(d: string | Date) {
return new Date(d).toLocaleDateString();
}
</script>
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Users</h1>
<span class="text-sm text-muted-foreground">{data.total} total</span>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3 mb-4">
<Input
placeholder="Search email or name…"
class="max-w-xs"
value={searchValue}
oninput={(e) => {
searchValue = (e.target as HTMLInputElement).value;
debounceSearch(searchValue);
}}
/>
<div class="flex gap-1">
{#each roles as role (role)}
<Button
size="sm"
variant={data.role === role || (!data.role && role === "") ? "default" : "outline"}
onclick={() => setRole(role)}
>
{role || "All"}
</Button>
{/each}
</div>
</div>
<!-- Table -->
<div class="rounded-lg border border-border/40 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-muted/30">
<tr>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">User</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Email</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Role</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Joined</th>
<th class="px-4 py-3 text-right font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-border/30">
{#each data.items as user (user.id)}
<tr class="hover:bg-muted/10 transition-colors">
<td class="px-4 py-3">
<div class="flex items-center gap-3">
{#if user.avatar}
<img
src={getAssetUrl(user.avatar, "mini")}
alt=""
class="h-8 w-8 rounded-full object-cover"
/>
{:else}
<div
class="h-8 w-8 rounded-full bg-primary/20 flex items-center justify-center text-xs font-semibold text-primary"
>
{(user.artist_name || user.email)[0].toUpperCase()}
</div>
{/if}
<span class="font-medium">{user.artist_name || user.first_name || "—"}</span>
</div>
</td>
<td class="px-4 py-3 text-muted-foreground">{user.email}</td>
<td class="px-4 py-3">
<select
class="rounded border border-border/40 bg-background px-2 py-1 text-xs disabled:opacity-50"
value={user.role}
disabled={user.id === currentUserId || updatingId === user.id}
onchange={(e) => changeUserRole(user, (e.target as HTMLSelectElement).value)}
>
<option value="viewer">Viewer</option>
<option value="model">Model</option>
<option value="admin">Admin</option>
</select>
</td>
<td class="px-4 py-3 text-muted-foreground">{formatDate(user.date_created)}</td>
<td class="px-4 py-3 text-right">
<Button
size="sm"
variant="ghost"
class="text-destructive hover:text-destructive hover:bg-destructive/10"
disabled={user.id === currentUserId}
onclick={() => confirmDelete(user)}
>
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
</Button>
</td>
</tr>
{/each}
{#if data.items.length === 0}
<tr>
<td colspan="5" class="px-4 py-8 text-center text-muted-foreground">No users found</td>
</tr>
{/if}
</tbody>
</table>
</div>
<!-- Pagination -->
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4">
<span class="text-sm text-muted-foreground">
Showing {data.offset + 1}{Math.min(data.offset + data.limit, data.total)} of {data.total}
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={data.offset === 0}
onclick={() => {
const params = new URLSearchParams(page.url.searchParams);
params.set("offset", String(Math.max(0, data.offset - data.limit)));
goto(`?${params.toString()}`);
}}
>
Previous
</Button>
<Button
size="sm"
variant="outline"
disabled={data.offset + data.limit >= data.total}
onclick={() => {
const params = new URLSearchParams(page.url.searchParams);
params.set("offset", String(data.offset + data.limit));
goto(`?${params.toString()}`);
}}
>
Next
</Button>
</div>
</div>
{/if}
</div>
<!-- Delete confirmation dialog -->
<Dialog.Root bind:open={deleteOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Delete user</Dialog.Title>
<Dialog.Description>
Are you sure you want to permanently delete <strong
>{deleteTarget?.artist_name || deleteTarget?.email}</strong
>? This cannot be undone.
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button variant="outline" onclick={() => (deleteOpen = false)}>Cancel</Button>
<Button
variant="destructive"
disabled={deleting}
onclick={handleDelete}
>
{deleting ? "Deleting…" : "Delete"}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>