@@ -136,10 +136,10 @@ Admin-only pages (Registration, Race Control, Batch Certificates) show a centere
136136 - ` isPublicMode() ` — returns true when no admin key is configured
137137 - ` isPrivateMode() ` — returns true when viewer key is configured
1381382. Add routes :
139- - ` GET /admin /login?token=... ` — validates token , sets admin cookie , ` 302 ` redirects to ` / `
140- - ` POST /admin/login` — accepts ` { password } ` JSON body , validates , sets admin cookie
139+ - ` POST /auth /login` — unified login : accepts ` { password } ` , checks admin key then viewer key , sets appropriate cookie
140+ - ` GET /admin/login?token=... ` — validates HMAC token , sets admin cookie , ` 302 ` redirects to ` / `
141141 - ` POST /admin/logout ` — clears admin cookie
142- - ` POST /viewer/login ` — accepts ` { password } ` JSON body , validates , sets viewer cookie
142+ - ` POST /viewer/logout ` — clears viewer cookie
143143 - ` GET /admin/status ` — returns ` { admin: true/false, viewer: true/false, publicMode: true/false, privateMode: true/false } `
1441443. Wrap all ` POST ` / `PATCH`/ ` DELETE ` routes with ` adminOnly() ` except explicitly public ones
1451454. When ` DERBY_VIEWER_KEY ` is set , wrap all ` GET ` routes with ` viewerRequired() ` except login and healthcheck
@@ -149,7 +149,7 @@ Admin-only pages (Registration, Race Control, Batch Certificates) show a centere
149149
1501501. Add ` isAdmin ` , ` isViewer ` , ` isPublicMode ` , and ` isPrivateMode ` to ` AppContext `
1511512. On app load , call ` GET /admin/status ` to determine auth state
152- 3. If ` privateMode && !isViewer && !isAdmin ` , redirect to ` /viewer/login `
152+ 3. If ` privateMode && !isViewer && !isAdmin ` , show in - app ` PrivateLoginGate ` ( full - screen login form )
1531534. No URL param handling needed — the cookie flow is entirely server - side
154154
155155### Phase 3 : Frontend UI Updates
@@ -158,14 +158,14 @@ Admin-only pages (Registration, Race Control, Batch Certificates) show a centere
1581582. Show " Admin access required" banner on admin - only views when ` !isAdmin && !isPublicMode `
1591593. In public mode (no key set ), everything works as it does today — no banners , no restrictions
160160
161- ### Phase 4 : Admin Login Page
161+ ### Phase 4 : Admin Login UI
162162
163- 1. Create a simple ` /admin ` page :
164- - If ` isPublicMode ` : show " Public Mode — all users have admin access "
165- - If ` !isAdmin ` : show a password input form ( POST to ` /admin/login ` )
166- - If ` isAdmin ` : show QR code for other volunteers , local IP , " Revoke All Sessions " button
167- 2. Local mode : QR code is the primary login method
168- 3. Cloud mode : password form is the primary login method
163+ 1. Login dialog in the app shell ( no separate ` /admin ` page ) :
164+ - " Admin Login " button in the nav bar opens a password dialog
165+ - Dialog POSTs to ` /auth/login ` — the unified endpoint checks admin key then viewer key
166+ - On success , the app refreshes auth state and shows admin controls
167+ 2. Local mode : QR code at ` /admin/login?token=HMAC ` is the primary login method for volunteers
168+ 3. Cloud mode : the nav bar password dialog is the primary login method
169169
170170## 5. Cloud Deployment Considerations
171171
@@ -231,24 +231,25 @@ All test scripts set both keys and include the appropriate cookies in requests:
231231* ` POST /api/heats/:id/start ` with valid admin cookie → ` 200 `
232232* ` GET /admin/login?token=wrong ` → ` 401 `
233233* ` GET /admin/login?token=correct ` → ` 302 ` + ` Set-Cookie ` with ` HttpOnly `
234- * ` POST /admin/login ` with correct password → ` 200 ` + ` Set-Cookie `
234+ * ` POST /auth/login ` with admin password → ` 200 ` + ` Set-Cookie ` (` derby_admin ` ) + ` role: "admin" `
235+ * ` POST /auth/login ` with viewer password → ` 200 ` + ` Set-Cookie ` (` derby_viewer ` ) + ` role: "viewer" `
235236* ` GET /api/events ` without cookie (no viewer key ) → ` 200 ` (public read )
236237* ` GET /api/events ` without cookie (viewer key set ) → ` 401 `
237238* ` GET /api/events ` with viewer cookie (viewer key set ) → ` 200 `
238239* ` GET /api/events ` with admin cookie (viewer key set ) → ` 200 ` (admin implies viewer )
239240* ` POST /viewer/login ` with correct password → ` 200 ` + ` Set-Cookie ` (` derby_viewer ` )
240241* ` POST /viewer/login ` with wrong password → ` 401 `
241- * Rate limiting : 11 rapid login attempts → ` 429 ` (applies to both ` /admin/ login` and ` /viewer/login ` )
242+ * Rate limiting : 11 rapid login attempts → ` 429 ` (applies to all login endpoints )
242243* Cookie has ` Secure ` flag when request includes ` X-Forwarded-Proto: https `
243244
244245### E2E Tests (` e2e/auth.playwright.ts ` )
245246
246247* ** Guest Path ** : Navigate to ` /register ` . See read - only view with " Admin required" banner .
247- * ** Admin Path ** : Hit ` /admin/login?token=test-secret ` . Verify cookie is set , redirected to ` / ` , all actions available .
248- * ** Password Login ** : Submit password on ` /admin ` form . Verify cookie is set .
248+ * ** Admin Path ** : Hit ` /admin/login?token=HMAC ` . Verify cookie is set , redirected to ` / ` , all actions available .
249+ * ** Password Login ** : Use " Admin Login " button in nav , submit password . Verify cookie is set via ` /auth/login ` .
249250* ** Logout ** : Call ` /admin/logout ` . Verify admin actions disappear .
250251* ** Public Mode ** : Start server without ` DERBY_ADMIN_KEY ` . Verify no banners , full access .
251- * ** Private Mode ** : Start server with ` DERBY_VIEWER_KEY=test-viewer ` . Navigate to ` /standings ` — redirected to viewer login . Enter password , verify standings load .
252+ * ** Private Mode ** : Start server with ` DERBY_VIEWER_KEY=test-viewer ` . Navigate to ` / ` — see ` PrivateLoginGate ` . Enter viewer password , verify events page loads .
252253* ** Private Mode Admin ** : In private mode , admin cookie grants full access without needing viewer password .
253254
254255## 7. Related Plans
0 commit comments