feat(web): add /admin layout with role-gated load + sidebar
Hard route gate in admin/+layout.ts redirects non-admins to / before the layout (or any child) renders. AdminSidebar reads the active route from $app/state and applies a 12% accent-tinted bg + 2px forest-teal left strip on the active item. Overview landing shows pending-request count and Lidarr connected/unset, each linking to its sub-page. Shell nav exposes the Admin link only to is_admin users. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { LayoutGrid, Plug, ListChecks, ShieldX, Users, FolderTree } from 'lucide-svelte';
|
||||||
|
|
||||||
|
type Item = {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon: typeof LayoutGrid;
|
||||||
|
placeholder?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const items: Item[] = [
|
||||||
|
{ href: '/admin', label: 'Overview', icon: LayoutGrid },
|
||||||
|
{ href: '/admin/integrations', label: 'Integrations', icon: Plug },
|
||||||
|
{ href: '/admin/requests', label: 'Requests', icon: ListChecks },
|
||||||
|
{ href: '/admin/quarantine', label: 'Quarantine', icon: ShieldX, placeholder: true },
|
||||||
|
{ href: '/admin/users', label: 'Users', icon: Users, placeholder: true },
|
||||||
|
{ href: '/admin/library', label: 'Library', icon: FolderTree, placeholder: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
function isActive(href: string): boolean {
|
||||||
|
if (href === '/admin') return page.url.pathname === '/admin';
|
||||||
|
return page.url.pathname.startsWith(href);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="w-[220px] shrink-0 border-r border-border bg-surface">
|
||||||
|
<nav aria-label="Admin sections">
|
||||||
|
<ul class="space-y-1 p-2">
|
||||||
|
{#each items as item (item.href)}
|
||||||
|
<li>
|
||||||
|
{#if item.placeholder}
|
||||||
|
<span
|
||||||
|
class="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-text-muted opacity-60"
|
||||||
|
aria-disabled="true"
|
||||||
|
title="Coming soon"
|
||||||
|
>
|
||||||
|
<item.icon size={16} strokeWidth={1} />
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
aria-current={isActive(item.href) ? 'page' : undefined}
|
||||||
|
class="flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors {isActive(
|
||||||
|
item.href
|
||||||
|
)
|
||||||
|
? 'bg-accent-tint text-text-primary border-l-2 border-accent pl-[10px]'
|
||||||
|
: 'text-text-secondary hover:text-text-primary hover:bg-surface-hover'}"
|
||||||
|
>
|
||||||
|
<item.icon size={16} strokeWidth={1} />
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* "12% accent-tinted bg" — the design-system rule for active nav state.
|
||||||
|
:global is required because the class is composed inside a templated
|
||||||
|
class string the Svelte scoped-CSS pass can't see. */
|
||||||
|
:global(.bg-accent-tint) {
|
||||||
|
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
pageUrl: new URL('http://localhost/admin')
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$app/state', () => ({
|
||||||
|
page: { get url() { return state.pageUrl; } }
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AdminSidebar from './AdminSidebar.svelte';
|
||||||
|
|
||||||
|
describe('AdminSidebar', () => {
|
||||||
|
test('Overview link is active when on /admin', () => {
|
||||||
|
state.pageUrl = new URL('http://localhost/admin');
|
||||||
|
render(AdminSidebar);
|
||||||
|
const overview = screen.getByRole('link', { name: /overview/i });
|
||||||
|
expect(overview).toHaveAttribute('aria-current', 'page');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Integrations link is active when on /admin/integrations', () => {
|
||||||
|
state.pageUrl = new URL('http://localhost/admin/integrations');
|
||||||
|
render(AdminSidebar);
|
||||||
|
expect(screen.getByRole('link', { name: /integrations/i })).toHaveAttribute(
|
||||||
|
'aria-current',
|
||||||
|
'page'
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('link', { name: /overview/i })).not.toHaveAttribute('aria-current');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Requests link is active when on /admin/requests', () => {
|
||||||
|
state.pageUrl = new URL('http://localhost/admin/requests');
|
||||||
|
render(AdminSidebar);
|
||||||
|
expect(screen.getByRole('link', { name: /requests/i })).toHaveAttribute(
|
||||||
|
'aria-current',
|
||||||
|
'page'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('placeholder items render as non-links with aria-disabled', () => {
|
||||||
|
state.pageUrl = new URL('http://localhost/admin');
|
||||||
|
render(AdminSidebar);
|
||||||
|
expect(screen.queryByRole('link', { name: /quarantine/i })).not.toBeInTheDocument();
|
||||||
|
const quar = screen.getByText(/quarantine/i);
|
||||||
|
expect(quar.closest('[aria-disabled="true"]')).toBeInTheDocument();
|
||||||
|
// Users + Library are also placeholders today.
|
||||||
|
expect(screen.queryByRole('link', { name: /^users$/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('link', { name: /^library$/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,6 +30,13 @@
|
|||||||
{ href: '/playlists', label: 'Playlists' },
|
{ href: '/playlists', label: 'Playlists' },
|
||||||
{ href: '/settings', label: 'Settings' }
|
{ href: '/settings', label: 'Settings' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Admin link sits between Playlists and Settings, only visible to admins.
|
||||||
|
const visibleNavItems = $derived(
|
||||||
|
user.value?.is_admin
|
||||||
|
? [...navItems.slice(0, -1), { href: '/admin', label: 'Admin' }, navItems[navItems.length - 1]]
|
||||||
|
: navItems
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onclick={() => (menuOpen = false)} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} />
|
<svelte:window onclick={() => (menuOpen = false)} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} />
|
||||||
@@ -70,7 +77,7 @@
|
|||||||
|
|
||||||
<nav class="hidden w-48 border-r border-border bg-surface md:block">
|
<nav class="hidden w-48 border-r border-border bg-surface md:block">
|
||||||
<ul class="p-2">
|
<ul class="p-2">
|
||||||
{#each navItems as item}
|
{#each visibleNavItems as item}
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
|
|||||||
@@ -9,8 +9,18 @@ vi.mock('$app/navigation', () => ({
|
|||||||
goto: vi.fn()
|
goto: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mutable handle so individual tests can flip the user between admin /
|
||||||
|
// non-admin without re-importing the module.
|
||||||
|
const userState = vi.hoisted(() => ({
|
||||||
|
current: { id: '1', username: 'alice', is_admin: false } as {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
} | null
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/auth/store.svelte', () => ({
|
vi.mock('$lib/auth/store.svelte', () => ({
|
||||||
user: { value: { id: '1', username: 'alice', is_admin: false } },
|
user: { get value() { return userState.current; } },
|
||||||
logout: vi.fn().mockResolvedValue(undefined)
|
logout: vi.fn().mockResolvedValue(undefined)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -20,6 +30,7 @@ import { goto } from '$app/navigation';
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
userState.current = { id: '1', username: 'alice', is_admin: false };
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Shell', () => {
|
describe('Shell', () => {
|
||||||
@@ -37,6 +48,29 @@ describe('Shell', () => {
|
|||||||
expect(screen.getByRole('link', { name: 'Playlists' })).toHaveAttribute('href', '/playlists');
|
expect(screen.getByRole('link', { name: 'Playlists' })).toHaveAttribute('href', '/playlists');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('non-admin users do not see the Admin nav link', () => {
|
||||||
|
userState.current = { id: '1', username: 'alice', is_admin: false };
|
||||||
|
render(Shell);
|
||||||
|
expect(screen.queryByRole('link', { name: 'Admin' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin users see the Admin nav link between Playlists and Settings', () => {
|
||||||
|
userState.current = { id: '1', username: 'alice', is_admin: true };
|
||||||
|
render(Shell);
|
||||||
|
const link = screen.getByRole('link', { name: 'Admin' });
|
||||||
|
expect(link).toHaveAttribute('href', '/admin');
|
||||||
|
// Order check: Playlists then Admin then Settings.
|
||||||
|
const labels = screen
|
||||||
|
.getAllByRole('link')
|
||||||
|
.map((el) => el.textContent?.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const idxPlaylists = labels.indexOf('Playlists');
|
||||||
|
const idxAdmin = labels.indexOf('Admin');
|
||||||
|
const idxSettings = labels.indexOf('Settings');
|
||||||
|
expect(idxPlaylists).toBeLessThan(idxAdmin);
|
||||||
|
expect(idxAdmin).toBeLessThan(idxSettings);
|
||||||
|
});
|
||||||
|
|
||||||
test('user-menu "Log out" calls logout() and navigates to /login', async () => {
|
test('user-menu "Log out" calls logout() and navigates to /login', async () => {
|
||||||
render(Shell);
|
render(Shell);
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /alice/i }));
|
await fireEvent.click(screen.getByRole('button', { name: /alice/i }));
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Sword } from 'lucide-svelte';
|
||||||
|
import AdminSidebar from '$lib/components/AdminSidebar.svelte';
|
||||||
|
|
||||||
|
let { children } = $props<{ children: import('svelte').Snippet }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-background text-text-primary">
|
||||||
|
<header class="flex items-center gap-3 border-b border-border bg-surface px-4 py-3">
|
||||||
|
<Sword size={20} strokeWidth={1.5} class="text-action-destructive" />
|
||||||
|
<h1 class="font-display text-xl font-medium">Admin</h1>
|
||||||
|
</header>
|
||||||
|
<div class="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
<main class="flex-1 p-6">
|
||||||
|
{@render children?.()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { user } from '$lib/auth/store.svelte';
|
||||||
|
import type { LayoutLoad } from './$types';
|
||||||
|
|
||||||
|
// Hard route gate: runs before the layout (and any child page) renders.
|
||||||
|
// Auth is guaranteed bootstrapped by the root +layout.ts, so user.value is
|
||||||
|
// either a settled User object or null.
|
||||||
|
export const load: LayoutLoad = () => {
|
||||||
|
if (!user.value || !user.value.is_admin) {
|
||||||
|
throw redirect(302, '/');
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createAdminRequestsQuery, createLidarrConfigQuery } from '$lib/api/admin';
|
||||||
|
|
||||||
|
const requestsStore = createAdminRequestsQuery('pending');
|
||||||
|
const requests = $derived($requestsStore);
|
||||||
|
|
||||||
|
const configStore = createLidarrConfigQuery();
|
||||||
|
const config = $derived($configStore);
|
||||||
|
|
||||||
|
const pendingCount = $derived(requests.data?.length ?? 0);
|
||||||
|
const lidarrConnected = $derived(config.data?.enabled === true);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<header>
|
||||||
|
<h2 class="font-display text-2xl font-medium text-text-primary">Overview</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<a
|
||||||
|
href="/admin/requests"
|
||||||
|
class="block rounded-xl border border-border bg-surface p-5 transition-colors hover:bg-surface-hover"
|
||||||
|
>
|
||||||
|
<div class="text-sm text-text-secondary">Pending requests</div>
|
||||||
|
<div class="font-display mt-1 text-3xl font-medium text-text-primary">{pendingCount}</div>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/integrations"
|
||||||
|
class="block rounded-xl border border-border bg-surface p-5 transition-colors hover:bg-surface-hover"
|
||||||
|
>
|
||||||
|
<div class="text-sm text-text-secondary">Lidarr</div>
|
||||||
|
<div class="font-display mt-1 text-2xl font-medium text-text-primary">
|
||||||
|
{lidarrConnected ? 'Connected' : 'Unset'}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import { mockQuery } from '../../test-utils/query';
|
||||||
|
|
||||||
|
// Mutable mock-user handle so individual tests can flip is_admin / null
|
||||||
|
// without re-importing the module under test.
|
||||||
|
const userState = vi.hoisted(() => ({
|
||||||
|
current: null as { id: string; username: string; is_admin: boolean } | null
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/auth/store.svelte', () => ({
|
||||||
|
user: { get value() { return userState.current; } }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$app/state', () => ({
|
||||||
|
page: { url: new URL('http://localhost/admin') }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/api/admin', () => ({
|
||||||
|
createAdminRequestsQuery: vi.fn(),
|
||||||
|
createLidarrConfigQuery: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
userState.current = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('admin/+layout.ts load gate', () => {
|
||||||
|
test('non-admin user throws redirect to /', async () => {
|
||||||
|
userState.current = { id: 'u1', username: 'alice', is_admin: false };
|
||||||
|
const { load } = await import('./+layout');
|
||||||
|
await expect(async () =>
|
||||||
|
// The SvelteKit `load` signature wants a LoadEvent; we don't read any of
|
||||||
|
// it inside the function, so an empty cast is sufficient for this gate.
|
||||||
|
load({} as Parameters<typeof load>[0])
|
||||||
|
).rejects.toMatchObject({ status: 302, location: '/' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unauthenticated (null user) throws redirect to /', async () => {
|
||||||
|
userState.current = null;
|
||||||
|
const { load } = await import('./+layout');
|
||||||
|
await expect(async () =>
|
||||||
|
load({} as Parameters<typeof load>[0])
|
||||||
|
).rejects.toMatchObject({ status: 302, location: '/' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin user passes the gate', async () => {
|
||||||
|
userState.current = { id: 'u1', username: 'root', is_admin: true };
|
||||||
|
const { load } = await import('./+layout');
|
||||||
|
// load() is synchronous in the happy path; it only throws on the gate.
|
||||||
|
expect(load({} as Parameters<typeof load>[0])).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('admin Overview page', () => {
|
||||||
|
test('renders pending requests count and Lidarr status (connected)', async () => {
|
||||||
|
const { createAdminRequestsQuery, createLidarrConfigQuery } = await import('$lib/api/admin');
|
||||||
|
(createAdminRequestsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
|
mockQuery({ data: [{ id: '1' }, { id: '2' }] })
|
||||||
|
);
|
||||||
|
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
|
mockQuery({ data: { enabled: true } })
|
||||||
|
);
|
||||||
|
const { default: OverviewPage } = await import('./+page.svelte');
|
||||||
|
render(OverviewPage);
|
||||||
|
expect(screen.getByText('Pending requests')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Lidarr')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/connected/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows zero pending and "Unset" when nothing is configured', async () => {
|
||||||
|
const { createAdminRequestsQuery, createLidarrConfigQuery } = await import('$lib/api/admin');
|
||||||
|
(createAdminRequestsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
|
mockQuery({ data: [] })
|
||||||
|
);
|
||||||
|
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
|
mockQuery({ data: { enabled: false } })
|
||||||
|
);
|
||||||
|
const { default: OverviewPage } = await import('./+page.svelte');
|
||||||
|
render(OverviewPage);
|
||||||
|
expect(screen.getByText('0')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/unset/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Overview cards link to their sub-pages', async () => {
|
||||||
|
const { createAdminRequestsQuery, createLidarrConfigQuery } = await import('$lib/api/admin');
|
||||||
|
(createAdminRequestsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
|
mockQuery({ data: [] })
|
||||||
|
);
|
||||||
|
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
|
mockQuery({ data: { enabled: false } })
|
||||||
|
);
|
||||||
|
const { default: OverviewPage } = await import('./+page.svelte');
|
||||||
|
render(OverviewPage);
|
||||||
|
const reqLink = screen.getByRole('link', { name: /pending requests/i });
|
||||||
|
expect(reqLink).toHaveAttribute('href', '/admin/requests');
|
||||||
|
const lidarrLink = screen.getByRole('link', { name: /lidarr/i });
|
||||||
|
expect(lidarrLink).toHaveAttribute('href', '/admin/integrations');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user