From bfd48f5a02216812d62a96f5f6699114e1d7aa14 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 22:48:26 -0400 Subject: [PATCH] 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 --- web/src/lib/components/AdminSidebar.svelte | 68 +++++++++++++ web/src/lib/components/AdminSidebar.test.ts | 51 ++++++++++ web/src/lib/components/Shell.svelte | 9 +- web/src/lib/components/Shell.test.ts | 36 ++++++- web/src/routes/admin/+layout.svelte | 19 ++++ web/src/routes/admin/+layout.ts | 13 +++ web/src/routes/admin/+page.svelte | 37 +++++++ web/src/routes/admin/admin.test.ts | 102 ++++++++++++++++++++ 8 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 web/src/lib/components/AdminSidebar.svelte create mode 100644 web/src/lib/components/AdminSidebar.test.ts create mode 100644 web/src/routes/admin/+layout.svelte create mode 100644 web/src/routes/admin/+layout.ts create mode 100644 web/src/routes/admin/+page.svelte create mode 100644 web/src/routes/admin/admin.test.ts diff --git a/web/src/lib/components/AdminSidebar.svelte b/web/src/lib/components/AdminSidebar.svelte new file mode 100644 index 00000000..8097f1e9 --- /dev/null +++ b/web/src/lib/components/AdminSidebar.svelte @@ -0,0 +1,68 @@ + + + + + diff --git a/web/src/lib/components/AdminSidebar.test.ts b/web/src/lib/components/AdminSidebar.test.ts new file mode 100644 index 00000000..792913e7 --- /dev/null +++ b/web/src/lib/components/AdminSidebar.test.ts @@ -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(); + }); +}); diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index 9c83f655..9fc18724 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -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 + ); (menuOpen = false)} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} /> @@ -70,7 +77,7 @@