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