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:
2026-04-29 22:48:26 -04:00
parent dd86ffca94
commit bfd48f5a02
8 changed files with 333 additions and 2 deletions
@@ -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();
});
});
+8 -1
View File
@@ -30,6 +30,13 @@
{ href: '/playlists', label: 'Playlists' },
{ 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>
<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">
<ul class="p-2">
{#each navItems as item}
{#each visibleNavItems as item}
<li>
<a
href={item.href}
+35 -1
View File
@@ -9,8 +9,18 @@ vi.mock('$app/navigation', () => ({
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', () => ({
user: { value: { id: '1', username: 'alice', is_admin: false } },
user: { get value() { return userState.current; } },
logout: vi.fn().mockResolvedValue(undefined)
}));
@@ -20,6 +30,7 @@ import { goto } from '$app/navigation';
afterEach(() => {
vi.clearAllMocks();
userState.current = { id: '1', username: 'alice', is_admin: false };
});
describe('Shell', () => {
@@ -37,6 +48,29 @@ describe('Shell', () => {
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 () => {
render(Shell);
await fireEvent.click(screen.getByRole('button', { name: /alice/i }));