Standardize on the .agents/ directory (shared with project skills): phases/, user_stories/, reports/, screenshots/, validate.sh, and phase-sessions/ + pipeline.log all move to .agents/ (git mv preserves history; runtime artifacts move alongside). Updates every reference in AGENTS.md, README.md, .gitignore, app docstrings, and test story headers. Historical KB content in data/ and the runtime pipeline.log transcript are left untouched.
86 lines
4.8 KiB
Markdown
86 lines
4.8 KiB
Markdown
# Story: Admin Sign-In (Single-Admin Auth)
|
|
|
|
**Phase:** `16_admin_auth.md` · **E2E:** `tests/e2e/test_admin_auth.py`
|
|
|
|
## Narrative
|
|
|
|
As **the one admin (the owner)**, I want to **sign in with a password** so
|
|
that I — and only I — can **tune how Brain answers** (Phase 15 steering
|
|
notes) and **browse the entire Sources catalog**, while **anyone else stays
|
|
anonymous** and can still **chat** and **open the documents the chat cites**.
|
|
|
|
- **Given** I am not signed in
|
|
- **When** I open the app
|
|
- **Then** chat, suggestion chips, and the document viewer all work, but the
|
|
tuning UI is gone (no Tune button, no Tuning panel) and the Sources page
|
|
shows a "sign in to view" state; a **Sign in** link is in the header.
|
|
- **When** I sign in with the admin password
|
|
- **Then** the tuning UI and the full Sources page are unlocked, and the
|
|
header offers **Sign out**.
|
|
- Anonymous users may open **any indexed document by direct URL** (soft
|
|
rule — the catalog is what is gated, not the viewer).
|
|
|
|
## Acceptance criteria
|
|
1. **Config (fail-loud):** `BOR_ADMIN_PASSWORD` (plaintext, `.env`) and
|
|
`BOR_SESSION_SECRET` (random hex) — if either is unset the app **refuses
|
|
to start** with a message naming the missing variable(s).
|
|
2. `POST /api/login {password}` → **204** + signed session cookie
|
|
(Starlette `SessionMiddleware`, `same_site=lax`, 12h default max age);
|
|
wrong password → **401** `{"detail": "invalid password"}` (constant-time
|
|
compare, one generic message — no user enumeration).
|
|
3. `POST /api/logout` → **204** (session cleared, idempotent);
|
|
`GET /api/whoami` → `{"authenticated": bool, "role": "admin"|"anonymous"}`.
|
|
4. **Admin-only:** `GET /api/docs` and `GET`/`POST`/`DELETE` `/api/steering`
|
|
→ **403** for anonymous. **Public (unchanged):** `/api/chat`,
|
|
`/api/documents/content`, `/api/suggestions`, `/api/health`, all static
|
|
pages.
|
|
5. **Login page** `/login.html` (no CDN, Phase-08 tokens, WCAG 2.1 AA):
|
|
app-frame header, labeled password field, submit ≥44px, `role=alert`
|
|
error region; on success redirects to `?next` (same-origin only, default
|
|
`/sources.html`); an already-signed-in visit redirects immediately.
|
|
6. **Chat page:** header shows **Sign in** when anonymous (→
|
|
`/login.html?next=/sources.html`) or **Sign out** when admin (logout →
|
|
reload); when anonymous the Tune button (including on localStorage
|
|
restored messages, Phase 14) and the Tuning panel/toggle are completely
|
|
hidden; the chat flow itself is unchanged.
|
|
7. **Sources page:** anonymous sees `#sources-gate` ("sign in to view the
|
|
full catalog" + sign-in link) with stat cards and `#docs-table` hidden and
|
|
no `/api/docs` call; admin sees the current full Sources page.
|
|
8. **Document viewer:** unchanged — anonymous can open any indexed document
|
|
by direct URL.
|
|
9. Non-goals: no rate limiting / account lockout, no HTTPS enforcement
|
|
(homelab HTTP), no multi-user, no per-user history, no schema change.
|
|
10. Unit + integration green, `app/` coverage >90%, story E2E green in
|
|
isolation, regression suites adapted + green in isolation, one
|
|
`--no-gpg-sign` commit.
|
|
|
|
## Playwright Mapping Rule
|
|
**Test Scenario → `tests/e2e/test_admin_auth.py`** (the E2E app server is
|
|
started with `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` set in
|
|
`tests/e2e/conftest.py`; the shared `tests/e2e/auth_helpers.py::login`
|
|
performs the real form login):
|
|
1. `test_anonymous_chat_without_tuning` — question → streamed answer renders;
|
|
no `.tune-btn` under the brain bubble; `#steering-toggle` absent;
|
|
`#sign-in-link` visible, `#sign-out-btn` not.
|
|
2. `test_anonymous_sources_gated_viewer_open` — `/sources.html`:
|
|
`#sources-gate` visible, `#docs-table` hidden; then
|
|
`/document.html?source=…&path=…` (seeded fixture doc) renders its content
|
|
while anonymous.
|
|
3. `test_login_wrong_password_shows_error` — wrong password → `#login-error`
|
|
(role=alert) visible, no redirect, still anonymous.
|
|
4. `test_admin_login_unlocks_sources_and_tuning` — correct password →
|
|
redirect to `/sources.html` with stats + table visible; chat page shows
|
|
`#steering-toggle` (count badge) and `.tune-btn` under the answer;
|
|
`#sign-out-btn` visible, `#sign-in-link` not.
|
|
5. `test_logout_returns_to_anonymous` — Sign out → tuning hidden,
|
|
`#sign-in-link` back, `/sources.html` gated again.
|
|
6. `test_login_page_a11y` — password field labeled, error announced via
|
|
role=alert, targets ≥44px, focus-visible; signed-in visit to
|
|
`/login.html?next=/` redirects immediately.
|
|
|
|
**Regression adaptations (this phase makes their preconditions
|
|
admin-only):** `test_steering.py` (log in before tuning),
|
|
`test_import_documents.py` + `test_document_back_navigation.py` (log in for
|
|
Sources-table assertions), `test_header_consistency.py` (header gains the
|
|
auth link — extend presence assertions; heights unchanged).
|