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,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