diff --git a/apps/admin/src/App.test.tsx b/apps/admin/src/App.test.tsx index 187343a..fda4bce 100644 --- a/apps/admin/src/App.test.tsx +++ b/apps/admin/src/App.test.tsx @@ -1,7 +1,21 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import App from './App'; +import { clearToken, getToken, setToken } from './lib/auth-storage'; + +// Stub the network layer so the authed shell renders without real requests. +// /api/me resolves an admin so the gate stays authed and the header shows the email. +vi.mock('./lib/api', async () => { + const actual = await vi.importActual('./lib/api'); + return { + ...actual, + apiFetch: vi.fn().mockResolvedValue({ + user: { id: 'u1', email: 'admin@solelog.local', name: 'Admin', role: 'admin' }, + }), + }; +}); function renderApp() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -13,8 +27,30 @@ function renderApp() { } describe('App', () => { - it('renders the admin app name', () => { + afterEach(() => { + clearToken(); + }); + + it('shows the login screen when there is no token', () => { + clearToken(); renderApp(); - expect(screen.getByText('SoleLog Admin')).toBeInTheDocument(); + expect(screen.getByText('E-mailadres')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Inloggen' })).toBeInTheDocument(); + }); + + it('shows the sidebar nav when a token is present', () => { + setToken('tok'); + renderApp(); + const nav = within(screen.getByRole('navigation')); + expect(nav.getByText('Live')).toBeInTheDocument(); + expect(nav.getByText('Handelingen')).toBeInTheDocument(); + }); + + it('clears the token when logout is clicked', async () => { + setToken('tok'); + renderApp(); + expect(getToken()).toBe('tok'); + await userEvent.click(screen.getByRole('button', { name: 'Uitloggen' })); + expect(getToken()).toBeNull(); }); }); diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 15b56b0..34fceda 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -1,9 +1,24 @@ +import { BrowserRouter, Route, Routes } from 'react-router-dom'; import { AuthProvider, useAuth } from './auth/AuthContext'; import Login from './screens/Login'; +import Sidebar from './components/Sidebar'; +import Live from './screens/Live'; +import Activities from './screens/Activities'; function AuthedShell() { - // Placeholder shell — replaced by the sidebar + routing in Task 4. - return
SoleLog Admin
; + return ( + +
+ +
+ + } /> + } /> + +
+
+
+ ); } function Gate() { diff --git a/apps/admin/src/components/Sidebar.tsx b/apps/admin/src/components/Sidebar.tsx new file mode 100644 index 0000000..863c2e2 --- /dev/null +++ b/apps/admin/src/components/Sidebar.tsx @@ -0,0 +1,52 @@ +import { NavLink } from 'react-router-dom'; +import { useAuth } from '../auth/AuthContext'; +import { useMe } from '../api/me'; + +const navItems = [ + { to: '/', label: 'Live' }, + { to: '/handelingen', label: 'Handelingen' }, +] as const; + +// Sections planned for Phase 3b — shown muted/disabled as a hint of what's coming. +const soonItems = ['Rapporten', 'Gebruikers', 'Handmatig'] as const; + +export default function Sidebar() { + const { signOut } = useAuth(); + const meQuery = useMe(); + const email = meQuery.data?.user.email; + + return ( + + ); +} diff --git a/apps/admin/src/screens/Activities.tsx b/apps/admin/src/screens/Activities.tsx new file mode 100644 index 0000000..ac5ecdd --- /dev/null +++ b/apps/admin/src/screens/Activities.tsx @@ -0,0 +1,4 @@ +// Placeholder — replaced by the activity-management view in Task 6. +export default function Activities() { + return
Handelingen
; +} diff --git a/apps/admin/src/screens/Live.tsx b/apps/admin/src/screens/Live.tsx new file mode 100644 index 0000000..e85170c --- /dev/null +++ b/apps/admin/src/screens/Live.tsx @@ -0,0 +1,4 @@ +// Placeholder — replaced by the live active-work view in Task 5. +export default function Live() { + return
Live
; +} diff --git a/apps/admin/src/styles.css b/apps/admin/src/styles.css index e69de29..acbd601 100644 --- a/apps/admin/src/styles.css +++ b/apps/admin/src/styles.css @@ -0,0 +1,229 @@ +:root { + --primary: #2563eb; + --primary-light: #eff6ff; + --text: #111827; + --text-muted: #6b7280; + --border: #e5e7eb; + --danger: #dc2626; + --amber: #d97706; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: + 'Inter', + system-ui, + -apple-system, + sans-serif; + color: var(--text); + background: #f9fafb; +} + +/* ---- Admin shell layout (desktop) ---- */ +.admin-shell { + display: grid; + grid-template-columns: 220px 1fr; + min-height: 100vh; +} + +.admin-main { + padding: 32px; + overflow-y: auto; +} + +.screen { + max-width: 960px; + margin: 0 auto; +} + +.screen-title { + font-size: 28px; + font-weight: 600; + margin: 0 0 8px; +} + +.screen-subtitle { + margin: 0 0 24px; + color: var(--text-muted); + font-size: 15px; +} + +/* ---- Sidebar ---- */ +.sidebar { + display: flex; + flex-direction: column; + gap: 24px; + padding: 24px 16px; + background: #ffffff; + border-right: 1px solid var(--border); +} + +.sidebar-brand { + font-size: 18px; + font-weight: 700; + color: var(--primary); + padding: 0 8px; +} + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 4px; +} + +.nav-link { + display: block; + padding: 10px 12px; + font-size: 15px; + font-weight: 500; + text-decoration: none; + color: var(--text-muted); + border-radius: 10px; +} + +.nav-link:hover { + background: #f3f4f6; +} + +.nav-link-active { + color: var(--primary); + background: var(--primary-light); +} + +.nav-soon { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +.nav-soon-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + padding: 0 12px 4px; +} + +.nav-disabled { + display: block; + padding: 10px 12px; + font-size: 15px; + font-weight: 500; + color: var(--border); + cursor: not-allowed; +} + +/* ---- Topbar (signed-in identity + logout) ---- */ +.topbar { + margin-top: auto; + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +.topbar-email { + font-size: 13px; + color: var(--text-muted); + padding: 0 8px; + word-break: break-all; +} + +.btn-logout { + width: 100%; + padding: 10px; + font-size: 14px; + font-weight: 600; + color: var(--danger); + background: #ffffff; + border: 1px solid var(--danger); + border-radius: 12px; + cursor: pointer; +} + +/* ---- Login screen ---- */ +.login { + max-width: 420px; + margin: 0 auto; + padding: 48px 24px; + display: flex; + flex-direction: column; + gap: 24px; + min-height: 100vh; + justify-content: center; +} + +.login-title { + font-size: 36px; + font-weight: 600; + text-align: center; + color: var(--primary); + margin: 0; +} + +.login-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.field-label { + font-size: 14px; + font-weight: 500; + color: var(--text-muted); +} + +.field-input { + width: 100%; + padding: 14px 16px; + font-size: 16px; + border: 1px solid var(--border); + border-radius: 12px; + background: #ffffff; + color: var(--text); +} + +.field-input:focus { + outline: none; + border-color: var(--primary); +} + +.btn-primary { + width: 100%; + padding: 16px; + font-size: 16px; + font-weight: 600; + color: #ffffff; + background: var(--primary); + border: none; + border-radius: 16px; + cursor: pointer; +} + +.btn-primary:disabled { + background: var(--border); + color: var(--text-muted); + cursor: not-allowed; +} + +.login-error { + color: var(--danger); + font-size: 14px; + margin: 0; + text-align: center; +}