From 77659edf8e0f12470fb9ecb42bb1ea3c19c67040 Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Wed, 17 Jun 2026 18:59:43 +0200 Subject: [PATCH] feat(admin): bearer auth with admin-only gate + login screen --- apps/admin/src/App.tsx | 19 ++++- apps/admin/src/api/me.ts | 16 +++++ apps/admin/src/auth/AuthContext.test.tsx | 92 ++++++++++++++++++++++++ apps/admin/src/auth/AuthContext.tsx | 50 +++++++++++++ apps/admin/src/screens/Login.tsx | 57 +++++++++++++++ 5 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 apps/admin/src/api/me.ts create mode 100644 apps/admin/src/auth/AuthContext.test.tsx create mode 100644 apps/admin/src/auth/AuthContext.tsx create mode 100644 apps/admin/src/screens/Login.tsx diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 5fa5b84..15b56b0 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -1,3 +1,20 @@ -export default function App() { +import { AuthProvider, useAuth } from './auth/AuthContext'; +import Login from './screens/Login'; + +function AuthedShell() { + // Placeholder shell — replaced by the sidebar + routing in Task 4. return
SoleLog Admin
; } + +function Gate() { + const { isAuthed } = useAuth(); + return isAuthed ? : ; +} + +export default function App() { + return ( + + + + ); +} diff --git a/apps/admin/src/api/me.ts b/apps/admin/src/api/me.ts new file mode 100644 index 0000000..2a51cbe --- /dev/null +++ b/apps/admin/src/api/me.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query'; +import type { MeResponse } from '@solelog/shared'; +import { apiFetch } from '../lib/api'; + +// Fetch the signed-in user (id, email, name, role). +export function fetchMe(): Promise { + return apiFetch('/api/me'); +} + +// react-query wrapper around fetchMe; used by authed screens (e.g. the shell header). +export function useMe() { + return useQuery({ + queryKey: ['me'], + queryFn: fetchMe, + }); +} diff --git a/apps/admin/src/auth/AuthContext.test.tsx b/apps/admin/src/auth/AuthContext.test.tsx new file mode 100644 index 0000000..d8b847f --- /dev/null +++ b/apps/admin/src/auth/AuthContext.test.tsx @@ -0,0 +1,92 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AuthProvider, useAuth } from './AuthContext'; +import { fetchMe } from '../api/me'; +import { signIn as apiSignIn } from '../lib/api'; +import { clearToken, getToken, setToken } from '../lib/auth-storage'; + +// Mock the network layer so no real requests are made. +vi.mock('../lib/api', () => ({ + signIn: vi.fn(), +})); +vi.mock('../api/me', () => ({ + fetchMe: vi.fn(), +})); + +const mockedSignIn = vi.mocked(apiSignIn); +const mockedFetchMe = vi.mocked(fetchMe); + +// A tiny harness that exposes the auth context's state + signIn. +function Harness() { + const { isAuthed, signIn } = useAuth(); + return ( +
+ {String(isAuthed)} + + +
+ ); +} + +function renderHarness() { + return render( + + + + ); +} + +describe('AuthContext admin gate', () => { + beforeEach(() => { + mockedSignIn.mockReset(); + mockedFetchMe.mockReset(); + // signIn stores a token (mirror the real lib/api behaviour). + mockedSignIn.mockImplementation(async () => { + setToken('tok'); + }); + }); + + afterEach(() => { + clearToken(); + vi.clearAllMocks(); + }); + + it('signs in an admin: isAuthed becomes true, token kept', async () => { + mockedFetchMe.mockResolvedValue({ + user: { id: 'a1', email: 'admin@solelog.local', name: 'Admin', role: 'admin' }, + }); + const user = userEvent.setup(); + renderHarness(); + expect(screen.getByTestId('authed')).toHaveTextContent('false'); + + await user.click(screen.getByRole('button', { name: 'go' })); + + await waitFor(() => expect(screen.getByTestId('authed')).toHaveTextContent('true')); + expect(getToken()).toBe('tok'); + }); + + it('rejects a worker: throws not-admin, clears the token, stays unauthed', async () => { + mockedFetchMe.mockResolvedValue({ + user: { id: 'w1', email: 'worker@solelog.local', name: 'Werker', role: 'worker' }, + }); + const user = userEvent.setup(); + renderHarness(); + + await user.click(screen.getByRole('button', { name: 'go' })); + + await waitFor(() => expect(document.getElementById('err')).toHaveTextContent('not-admin')); + expect(getToken()).toBeNull(); + expect(screen.getByTestId('authed')).toHaveTextContent('false'); + }); +}); diff --git a/apps/admin/src/auth/AuthContext.tsx b/apps/admin/src/auth/AuthContext.tsx new file mode 100644 index 0000000..304bf4b --- /dev/null +++ b/apps/admin/src/auth/AuthContext.tsx @@ -0,0 +1,50 @@ +import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'; +import { clearToken, getToken } from '../lib/auth-storage'; +import { signIn as apiSignIn } from '../lib/api'; +import { fetchMe } from '../api/me'; + +// Thrown by signIn when the authenticated user is not an admin. Login distinguishes it +// from a plain auth failure to show a different message. +export class NotAdminError extends Error { + constructor() { + super('not-admin'); + this.name = 'NotAdminError'; + } +} + +interface AuthContextValue { + isAuthed: boolean; + signIn: (email: string, password: string) => Promise; + signOut: () => void; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [isAuthed, setIsAuthed] = useState(() => getToken() !== null); + + const signIn = useCallback(async (email: string, password: string) => { + await apiSignIn(email, password); + const me = await fetchMe(); + if (me.user.role !== 'admin') { + clearToken(); + throw new NotAdminError(); + } + setIsAuthed(true); + }, []); + + const signOut = useCallback(() => { + clearToken(); + setIsAuthed(false); + }, []); + + return ( + {children} + ); +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within an AuthProvider'); + return ctx; +} diff --git a/apps/admin/src/screens/Login.tsx b/apps/admin/src/screens/Login.tsx new file mode 100644 index 0000000..b0ff77c --- /dev/null +++ b/apps/admin/src/screens/Login.tsx @@ -0,0 +1,57 @@ +import { useState, type FormEvent } from 'react'; +import { NotAdminError, useAuth } from '../auth/AuthContext'; + +export default function Login() { + const { signIn } = useAuth(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(null); + setBusy(true); + try { + await signIn(email, password); + } catch (err) { + setError( + err instanceof NotAdminError ? 'Geen toegang — alleen beheerders.' : 'Inloggen mislukt' + ); + } finally { + setBusy(false); + } + } + + return ( +
+

SoleLog Admin

+
+ + + {error &&

{error}

} + +
+
+ ); +}