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
+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 }));