Author SHA1 Message Date
tliu93 e49a6ad963 M5(design): generic modbus_device + JSON readings; YAML profiles; Accordion config; manual CLI/MQTT testing
frontend / frontend (push) Successful in 1m26s
pytest / test (push) Successful in 3m52s
Rework the M5 design doc data model and add manual-testing surface:

- Two-layer model: rename energy_meters/energy_readings -> generic
  modbus_device + modbus_reading; readings become a JSON payload blob
  instead of a wide fixed-column table (aggregation via SQLite
  json_extract, generated-column index as a future lever).
- Protocol knowledge moves to read-only YAML profiles (data+functions,
  no OOP inheritance); deployment/config (friendly_name, unit_id, host)
  stays on the device row; multiple devices share one profile.
- UUID = stable internal identity, also anchors HA discovery unique_id
  (Z2M rename model); device metadata for HA derived from the profile.
- Naming scheme C: modbus_* / /api/modbus/devices at the data+API layer,
  'Energy' as the first domain view in the frontend.
- New task M5-T01B: ConfigPage Accordion sectioning (avoids a second
  in-page side nav conflicting with the T01 app sidebar).
- Manual testing: read-only modbus_cli 'probe' subcommand (FC03/04 only,
  no register writes); mqtt/test now publishes a test message viewable
  in MQTT Explorer; no MQTT CLI (manual verification via UI + HA).
2026-06-22 11:55:39 +02:00
tliu93 d70b985eb0 fix(ci): recompile dev-requirements.txt to include pyotp (pytest CI install)
pytest / test (push) Successful in 3m51s
frontend / frontend (push) Successful in 1m26s
2026-06-22 11:08:06 +02:00
tliu93 e3c47b2762 docs(CLAUDE): switch to direct-on-main workflow for this single-user repo
frontend / frontend (push) Successful in 1m35s
pytest / test (push) Failing after 31s
2026-06-22 10:37:34 +02:00
tliu93 dbf17f3592 docs(CLAUDE): pin default sub-agent models (Implementer/Fixer=Sonnet, Reviewer=Opus) with manual-override clause 2026-06-22 09:50:46 +02:00
tliu93 ae75e4582d M4-T09: document login hardening, finalize OpenAPI and roadmap 2026-06-21 23:01:50 +02:00
tliu93 ee1264b66b M4-T08: add two-step login and TOTP settings panel (qrcode.react) 2026-06-21 22:42:41 +02:00
tliu93 51da8c5716 M4-T07: add admin_cli disable-totp and reissue-totp escape commands 2026-06-21 22:27:07 +02:00
tliu93 81bd32f613 M4-T06: add TOTP second factor to login (totp_code + recovery codes) 2026-06-21 22:08:55 +02:00
tliu93 ee3031013e M4-T05: add TOTP service and setup/enable/disable/status API 2026-06-21 21:55:17 +02:00
tliu93 5026967cee M4-T04: add AuthUser TOTP columns and auth_recovery_code table 2026-06-21 21:38:22 +02:00
tliu93 5481fbc99c M4-T03: add admin_cli escape hatch (reset-password, unlock, list-admin) 2026-06-21 21:28:41 +02:00
tliu93 e480a84a44 M4-T02: add exponential login back-off service and wire into login endpoint 2026-06-21 21:20:28 +02:00
tliu93 64d3882c16 M4-T01: add auth_login_throttle table + LoginThrottle model 2026-06-21 21:03:20 +02:00
tliu93 6b67b8e0f8 modify compose dev port 2026-06-21 20:49:24 +02:00
tliu93 0cb94d85ec docs(design): plan M4 login-hardening (first) + M5 IoT/energy
- Add docs/references/SDM120-Modbus-Protocol.md extracted from the SDM120 PDF
- M4 login hardening: brute-force exponential backoff, CLI escape hatch
  (reset-password/unlock/disable-totp), optional TOTP 2FA
- M5 IoT/energy: Modbus-TCP SDM120 polling + energy tables, MQTT/HA
  Discovery expose framework, frontend sidebar + energy view
- Add manual acceptance walkthroughs (M4 lock/unlock; M5 energy_cli read)
- Index both milestones in docs/design/README.md
2026-06-21 20:46:12 +02:00
tliu93 9da88db221 docs(roadmap): graduate next-phase work out of Future Ideas
frontend / frontend (push) Successful in 1m18s
pytest / test (push) Successful in 1m32s
Promote four now-decided directions from Future Ideas into a new
"下一阶段:已确定要做" section (roadmap altitude, not yet broken into task cards):
TOTP second factor for the public dashboard, frontend optimization (scope TBD),
MQTT / IoT integration, and a settings-page long-lived API token (PAT-style,
related to but distinct from M3's mobile OAuth). Future Ideas is now an empty,
purpose-stated bucket for not-yet-decided ideas.
2026-06-13 17:38:50 +02:00
tliu93 1a3aaea933 fix(docker): pin frontend-build to $BUILDPLATFORM to avoid QEMU V8 crash
frontend / frontend (push) Successful in 1m19s
pytest / test (push) Successful in 1m31s
docker-image / build-and-push (push) Successful in 3m55s
Multi-arch image builds (linux/amd64,linux/arm64) emulate the foreign arch via
QEMU. The unpinned `FROM node:22-slim` frontend-build stage was built per
target arch, so Node ran under emulation and V8's baseline JIT crashed (SIGTRAP
/ exit 133 on `npm ci`) on the CI runner's QEMU.

Pin the stage to --platform=$BUILDPLATFORM so the static, arch-independent SPA
dist/ is built once on the native build host and COPYd into each arch's runtime
stage. Node never runs emulated; both arch images are still produced.
2026-06-13 17:21:05 +02:00
tliu93 bf7fd71a21 Merge pull request 'Feature/m2 frontend v2' (#8) from feature/m2-frontend-v2 into main
pytest / test (push) Successful in 1m29s
frontend / frontend (push) Successful in 1m15s
docker-image / build-and-push (push) Failing after 59s
Reviewed-on: #8
2026-06-13 17:00:19 +02:00
tliu93 962ba26c7c docs(roadmap): add Future Ideas — TOTP 2FA for the public dashboard
frontend / frontend (push) Successful in 1m15s
pytest / test (push) Successful in 1m30s
frontend / frontend (pull_request) Successful in 1m16s
pytest / test (pull_request) Successful in 1m30s
Record TOTP (RFC 6238) as a deferred hardening idea for the now public-facing
Web dashboard: second factor on the single-admin login, with CLI-only password
reset and a CLI TOTP reset/recovery path that works even if the recovery codes
are lost (no lock-out dead end). Not M2.5, not scheduled — parked under a new
Future Ideas section.
2026-06-13 15:29:20 +02:00
tliu93 da236643f2 M2: frontend walkthrough fixes + explicit dev compose stack
frontend / frontend (push) Successful in 2m0s
pytest / test (push) Successful in 1m32s
Post-M2 self-walkthrough polish, batched into one commit.

Map / heat:
- fix heat-layer white-screen crash after login (add layer to map before
  setLatLngs; an off-map leaflet.heat layer has a null _map and throws)
- normalize each heat layer to the densest pixel cell visible in the CURRENT
  viewport (maxZoom:0 so intensity factor f=1) and recompute on moveend/zoomend,
  so sparse poo data reaches red and stays normalized at any zoom level
- dark CARTO basemap tiles when the color scheme is dark

UI:
- dark-mode toggle in the top-right, beside the settings gear
- switch top-right nav (records / theme / settings / logout) to Feather icons
  with hover tooltips
- home: Grafana-style quick time-range presets + back/forward shift buttons,
  placed between the From/To pickers and Apply; fix Select/tooltip z-index
  (Leaflet stacking) and the shift-button height alignment

API client:
- stop flooding GET /api/session with 401s: the session probe and the login
  endpoint own their 401s (no global redirect), which fixes the logout hang and
  the spinning login page

Compose:
- rename docker-compose.override.yml -> docker-compose.dev.yml as an explicit,
  non-auto-layered dev stack (8001, -dev container names, prod-copy ./data DB);
  update tests/test_deployment.py (read dev.yml, tolerate the !override tag) and
  the README "Docker Compose" section

Tests:
- pixel-grid peak counter, time-range presets, heat-layer ordering regression,
  and 401-redirect regression
2026-06-13 15:20:50 +02:00
tliu93 bd09523e94 M2-T13: docs wrap-up + retire frontend constraints + dependency cleanup
- README: add 前端 v2 (React SPA) section (dev/build/codegen/hosting/gates),
  update directory listing, drop stale Jinja descriptions
- architecture-overview: retire '不引入前后端分离' constraint; reflect SPA + JSON API
- roadmap: mark M2 done
- remove orphaned jinja2 dependency (recompile requirements*.txt; no other churn)
- delete empty tests/test_auth.py stub; drop dead _extract_csrf_token in test_api_data
- verified image still builds and app imports with the slimmer deps
2026-06-13 15:20:50 +02:00
tliu93 53f1245d83 docs(m2): mark M2-T12 done 2026-06-13 15:20:50 +02:00
tliu93 51f712f602 M2-T12: multi-stage Dockerfile (node build -> python runtime) + frontend CI
- Dockerfile: node:22-slim stage runs npm ci + npm run build; python runtime
  stage COPY --from copies dist to /app/frontend/dist (matches SPA_DIST_DIR);
  runtime image has no node
- .dockerignore: exclude frontend/node_modules and frontend/dist from context
- .github/workflows/frontend.yml: npm ci + codegen-sync + lint/typecheck/test/build
- tests/test_deployment.py: skip COPY --from sources in the context-existence
  check; assert the multi-stage frontend build wiring
- verified with a real docker build (image serves SPA, no node at runtime)
2026-06-13 15:20:50 +02:00
tliu93 f8b1e5fc71 docs(m2): mark M2-T11 done 2026-06-13 15:20:50 +02:00
tliu93 a9830c42d8 M2-T11: serve React SPA from FastAPI and remove Jinja pages
- app/main.py serves the SPA build (SPA_DIST_DIR, default frontend/dist):
  mounts /assets and a GET catch-all returning index.html for client routes;
  catch-all 404s on /api/*, never swallows /docs, /openapi.json, /static, assets,
  ingestion/ticktick/status; skips SPA serving when dist absent (backend-only CI)
- delete app/api/routes/pages.py, app/api/routes/auth.py, app/templates/
  (all replaced by /api/* + SPA; auth service layer kept)
- remove/replace Jinja page tests (JSON coverage already in test_api_*);
  add tests/test_spa_hosting.py for the fallback contract
- regenerate openapi/ (Jinja paths gone) and frontend schema.d.ts
2026-06-13 15:20:50 +02:00
tliu93 8aa7316b26 docs(m2): mark M2-T09 done 2026-06-13 15:20:50 +02:00
tliu93 32d93bba2a M2-T09: build data visualization UI (heatmap map as home page)
- self-contained RecordsMap (only module importing leaflet/react-leaflet/
  leaflet.heat/leaflet.markercluster); OSM tiles, swappable behind clean props
- heatmap layers for location + poo (primary); time-range selector fetches
  only the window (locations server-filtered; poo client-filtered)
- toggleable scatter layer with marker clustering; point-select reuses T10's
  edit/delete modals + hooks; query-key prefixes refresh map on mutation
- pure map logic isolated + unit-tested; leaflet mocked in component tests
- responsive layout; typed client only
2026-06-13 15:20:50 +02:00
tliu93 0d988a9b28 docs(m2): mark M2-T10 done 2026-06-13 15:20:50 +02:00
tliu93 ef7ea6b971 M2-T10: build records management UI (paginated lists + single-record CRUD)
- reusable src/records/ module: useUpdate/useDelete Poo+Location hooks
  (encodeURIComponent PK, prefix-based query invalidation), EditPooModal,
  EditLocationModal, ConfirmDeleteModal — exported for the map (T09) to reuse
- RecordsPage (/records): paginated poo + location tables (page size 100),
  edit + delete-with-confirm, refresh on success
- query keys ['poo']/['locations'] so map and list invalidations cross-cut
- typed client only; vitest tests
2026-06-13 15:20:50 +02:00
tliu93 6cc6382515 docs(m2): mark M2-T08 done 2026-06-13 15:20:50 +02:00
tliu93 ef2bd3c9c5 M2-T08: build config UI (replaces Jinja config page)
- GET /api/config renders sections; secret fields shown as empty password inputs
- save handles full-field submission semantics: always send non-secret values,
  send secret only when user typed a new value (blank secret keeps old)
- SMTP test button reflects tri-state (success / config-error 400 / failed 502)
  by reading ApiError.body.result
- typed client only; responsive Mantine layout; vitest tests
2026-06-13 15:20:50 +02:00
tliu93 cc2c02a2e2 docs(m2): mark M2-T07 done 2026-06-13 15:20:50 +02:00
tliu93 b2e26f0b17 M2-T07: build auth UI (login, session bootstrap, forced password change, logout)
- real Mantine login form -> POST /api/auth/login; 401 inline error; redirect when already authed
- ProtectedRoute: loading state, preserves intended destination, gates force_password_change
- ChangePasswordPage forced-change gate -> POST /api/auth/password
- logout control in AppLayout nav -> POST /api/auth/logout
- typed client only; vitest tests for the login flow
2026-06-13 15:20:50 +02:00
tliu93 8975acc48b docs(m2): mark M2-T06 done 2026-06-13 15:20:50 +02:00
tliu93 6cfeb2b865 M2-T06: scaffold React SPA frontend with typed OpenAPI client
- Vite + React 18 + TypeScript + Mantine + TanStack Query + react-router-dom
- typed client: openapi-typescript -> src/api/schema.d.ts (committed), openapi-fetch
- fetch wrapper middleware: cookies, X-CSRF-Token on writes, 401 -> /login,
  non-401 errors carry parsed JSON body
- SessionProvider/useSession (GET /api/session), ProtectedRoute skeleton
- app shell (Mantine + router) with placeholder login/home/config pages + gear nav
- dev proxy to FastAPI; vitest smoke test; frontend README
- npm scripts: dev/build/preview/lint/typecheck/test/codegen
2026-06-13 15:20:50 +02:00
tliu93 dba9e28540 docs(m2): mark M2-T05 done 2026-06-13 15:20:50 +02:00
tliu93 2bc5d6ea9a M2-T05: add SMTP test action API (POST /api/config/smtp/test)
- reuses send_smtp_test_email; tri-state result success(200)/config-error(400)/failed(502)
- session + CSRF protected; never echoes SMTP secrets
- SmtpTestResponse schema; regenerate openapi/
- extend tests/test_api_config.py (3 states + 401 + missing-CSRF 403)
2026-06-13 15:20:50 +02:00
tliu93 3ec663e138 docs(m2): mark M2-T04 done 2026-06-12 23:35:56 +02:00
tliu93 048414c5cb M2-T04: add single-row record CRUD API (patch/delete)
- PATCH/DELETE /api/locations/{person}/{datetime} and /api/poo/{timestamp}
- update only non-PK fields (PK immutable); 404 on missing PK
- delete scoped to exact full PK with rowcount guard (0->404, 1->ok);
  no batch/truncate/drop path
- session + CSRF protected; bare ingestion endpoints untouched
- service helpers in app/services/location.py and poo.py; regenerate openapi/
- tests/test_api_record_crud.py
2026-06-12 23:33:08 +02:00
tliu93 9ce3f2a0b8 docs(m2): mark M2-T03 done 2026-06-12 23:27:02 +02:00
tliu93 0fba7cfe11 M2-T03: add read-only data JSON API
- GET /api/locations (inclusive time window start/end, pagination, cap 5000)
- GET /api/poo (pagination, cap 1000, newest first)
- GET /api/public-ip (current state + recent history, cap 1000)
- all session-protected, read-only, bounded (no full-table export)
- typed response schemas; register router; regenerate openapi/
- tests/test_api_data.py
2026-06-12 23:24:17 +02:00
tliu93 d8303eaa3d docs(m2): mark M2-T02 done 2026-06-12 23:18:43 +02:00
tliu93 8da1f13e60 M2-T02: add session/auth JSON API for the SPA
- GET /api/session (user + csrf_token, 401 when unauthenticated)
- POST /api/auth/login (sets HttpOnly session cookie; 401 on bad creds; no CSRF)
- POST /api/auth/logout (session+CSRF; revokes session, clears cookie; 204)
- POST /api/auth/password (session+CSRF; reuses change_password; 400 on failure; 204)
- reuses app/services/auth.py and shared require_session/require_csrf deps
- register router in app/main.py; regenerate openapi/
- tests/test_api_session.py
2026-06-12 23:15:56 +02:00
tliu93 de77019ce3 docs(m2): mark M2-T01 done 2026-06-12 23:11:38 +02:00
tliu93 c2b1b7b751 M2-T01: add config JSON API (GET/PUT /api/config)
- new app/api/routes/api/ package with shared require_session (401) and
  require_csrf (presence-only X-CSRF-Token, 403) dependencies
- GET /api/config returns masked config sections; PUT /api/config reuses
  save_config_updates (blank secret keeps old; invalid -> 422, no write)
- session-protected; PUT also CSRF-protected
- register router in app/main.py; regenerate openapi/
- tests/test_api_config.py
2026-06-12 23:08:14 +02:00
tliu93 3628ac51e5 chore(m2): green the ruff baseline before M2 orchestration
- ignore E402 in scripts/*.py (deliberate sys.path bootstrap before app imports)
- drop unused pathlib.Path import in tests/test_auth.py

Establishes a clean ruff gate so each M2 task can be verified green at its boundary.
2026-06-12 22:56:21 +02:00
tliu93 1756192270 docs: record future-ideas backlog and refine CLAUDE.md workflow rules
pytest / test (push) Successful in 46s
- Add docs/future-ideas.md: unscheduled backlog (more data sources/types,
  long-term storage, Home Assistant data persistence, MQTT client, near-term PWA).
- CLAUDE.md: codify M1 lessons — reviewer blind-review discipline,
  build-context integrity checks when deleting/moving files,
  fixup-vs-standalone-commit boundary, and a pre-release walkthrough
  (run app + docker build + manual smoke) before tagging.
2026-06-12 22:50:47 +02:00
tliu93 66ec9979cc docs(m2): lock M2 frontend design decisions
pytest / test (push) Failing after 11m46s
Record the decisions reached in planning into docs/design/m2-frontend-v2.md:
component library = Mantine; map = Leaflet (react-leaflet + leaflet.heat +
markercluster, isolated behind a component seam for a future MapLibre swap);
OpenAPI typed client committed + CI-checked; CSRF simplified to SameSite=Lax +
a custom write header (no per-session token); heatmap-first map as the home
view with a required time-range picker and an auxiliary paginated list;
record CRUD edits non-PK fields and deletes single rows (no UI create); bare
ingestion endpoints stay until M3; trips optional. Wireframes intentionally
skipped for this milestone.
2026-06-12 22:40:57 +02:00
tliu93 c1a5d7a425 fix(docker): stop COPYing removed alembic_location/alembic_poo into the image
pytest / test (push) Successful in 50s
docker-image / build-and-push (push) Successful in 3m52s
M1-T04 deleted the alembic_location / alembic_poo chains and their .ini files,
but the Dockerfile still COPYed those four paths, so the release image build
failed at 'COPY alembic_poo.ini ./' (path not found). Drop the four stale COPY
lines (only alembic_app remains). Add test_dockerfile_copy_sources_exist, which
asserts every Dockerfile COPY source exists in the build context, so this class
of breakage fails pytest instead of only surfacing in the image CI.

Verified with a real local 'docker build' (succeeds) and pytest (98 passed).
2026-06-12 20:48:34 +02:00
tliu93 1e0b235cef Merge pull request 'Feature/m1 db consolidation' (#7) from feature/m1-db-consolidation into main
pytest / test (push) Has been cancelled
docker-image / build-and-push (push) Has been cancelled
Reviewed-on: #7
2026-06-12 20:33:34 +02:00
119 changed files with 26827 additions and 1629 deletions
+3
View File
@@ -8,3 +8,6 @@ data
openapi openapi
src src
# Frontend host build artifacts — built inside the node stage, not needed from context
frontend/node_modules
frontend/dist
+49
View File
@@ -0,0 +1,49 @@
name: frontend
on:
push:
branches:
- "**"
pull_request:
workflow_dispatch:
jobs:
frontend:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Check codegen is in sync
working-directory: frontend
run: |
npm run codegen
git diff --exit-code src/api/schema.d.ts
- name: Lint
working-directory: frontend
run: npm run lint
- name: Type-check
working-directory: frontend
run: npm run typecheck
- name: Test
working-directory: frontend
run: npm run test
- name: Build
working-directory: frontend
run: npm run build
+53 -9
View File
@@ -36,14 +36,37 @@
- **默认逐步**:给一个 milestone 文档,按其中原子任务**一步一步**实现。 - **默认逐步**:给一个 milestone 文档,按其中原子任务**一步一步**实现。
- **(a) 只实现一步**:用户说"只实现一步 / 这一个任务"时,**只做那一个任务卡**,跑完校验闸门后停下,等用户确认,不要顺手往下做。 - **(a) 只实现一步**:用户说"只实现一步 / 这一个任务"时,**只做那一个任务卡**,跑完校验闸门后停下,等用户确认,不要顺手往下做。
- **(b) 完成整个 milestone**:仅当用户在提示词里**显式要求启用 sub-agent 并指定模型**时,才用指定模型起 implementer sub-agent,按任务依赖顺序跑完整条链。 - **(b) 完成整个 milestone**:仅当用户在提示词里**显式要求启用 sub-agent**时,才起 implementer / reviewer / fixer sub-agent(模型用下方**『默认模型档位』**,用户人工指定则覆盖),按任务依赖顺序跑完整条链。
- **Sub-agent 纪律**:只在用户显式要求时才 spawn sub-agent;单步/小改动在主线内联完成。起 sub-agent 时用用户**指定的模型**Agent 工具的 `model` 覆盖) - **Sub-agent 纪律**:只在用户显式要求时才 spawn sub-agent;单步/小改动在主线内联完成。起 sub-agent 时按下方**『默认模型档位』**选择模型(用户人工指定则以人工指定为准),用 Agent 工具的 `model` 字段落实
### 角色(Orchestrator → Implementer → Reviewer ### 默认模型档位(实现模式 sub-agent;可被人工指定覆盖
起 implementer / reviewer / fixer sub-agent 时,**默认**用下列模型档位,无需用户每次人工指定:
| 角色 | 默认模型 | 推理档位 |
| --- | --- | --- |
| **Implementer** | **Sonnet** | high reasoning effort |
| **Fixer**(返工) | **Sonnet** | high reasoning effort |
| **Reviewer** | **Opus** | extra-high reasoning effort |
- **人工指定覆盖**:若用户在提示词里**显式指定了其他模型**(针对任一角色),则**以用户人工指定为准**,覆盖上述默认。
- 用 Agent 工具的 `model` 字段落实模型选择;该字段当前仅支持 `sonnet` / `opus` / `haiku` / `fable`
- **推理档位说明**:Agent 工具未暴露独立的 reasoning-effort 旋钮,"high / extra-high reasoning" 通过 spawn prompt 里的显式指令传达(要求 implementer/fixer 动手前充分推理边界条件;要求 reviewer 以对抗性外部审计心态最高强度复核)。若所在 harness 提供真正的 effort 设置,则一并按此档位设置。
### 角色(Orchestrator → Implementer → Reviewer → Fixer
- 我(主线)= **Orchestrator**:挑依赖已满足的下一个任务、派发、转述结果、维护任务 `Status` - 我(主线)= **Orchestrator**:挑依赖已满足的下一个任务、派发、转述结果、维护任务 `Status`
- **Implementer**便宜模型,用户指定):一次一个任务,严格按任务卡,不扩范围。 - **Implementer**默认 **Sonnet**high reasoning;见上方『默认模型档位』):一次一个任务,严格按任务卡,不扩范围。
- **Reviewer**强模型,用户指定):实现完成后起 Reviewer sub-agent,按任务卡 `Acceptance criteria` + `Reviewer checklist` 复核、**独立重跑校验闸门**,驱动 implementer 返工直到本轮 PASS。 - **Reviewer**默认 **Opus**extra-high reasoning):实现完成后起 Reviewer sub-agent,按任务卡 `Acceptance criteria` + `Reviewer checklist` 复核、**独立重跑校验闸门**,驱动返工直到本轮 PASS。
- **Fixer**(默认 **Sonnet**high reasoning):按 reviewer 的编号返工清单返工;**每轮返工起一个干净的 Fixer**(与首次实现的 Implementer 分开冷启动),先读对应 `review-notes/<task>-review-<n>.md` 再改。
#### Reviewer 盲审纪律(M1 教训)
M1 里 review **从未触发过一次 rework**,根因是 orchestrator 把自己的结论 / 辩护喂给了 reviewer,造成 context bleed、review 沦为橡皮图章。所以:
- reviewer 必须**冷启动(Clear-Agent)、最小化喂料**——spawn prompt 只给:① 任务卡(`Acceptance criteria` + `Reviewer checklist`)、② 对应的 `review-notes/<task>-impl|rework-<n>.md` 路径、③ 要审的 diff / commit 范围。
- **不要**在 prompt 里塞 orchestrator 自己的判断、"我觉得没问题"、对实现选择的辩护,或上一轮 reviewer 的倾向性结论。让它**独立得出结论、独立重跑校验闸门**。
- 事后另起的整库**独立盲审**(如对抗复审)同理:Clear-Agent、最小上下文,把它当"**外部审计**"而非"确认自己没错"。
### 校验闸门(每个任务结束都要全绿) ### 校验闸门(每个任务结束都要全绿)
@@ -56,6 +79,14 @@ python scripts/export_openapi.py && git diff --exit-code openapi/ # 改了路
前端任务(M2)在 `frontend/` 下另跑 `npm run lint && npm run typecheck && npm run test && npm run build`(详见 m2 文档 §8)。 前端任务(M2)在 `frontend/` 下另跑 `npm run lint && npm run typecheck && npm run test && npm run build`(详见 m2 文档 §8)。
**不过闸门就不算完成**,不得跳过、不得留红给下一轮。 **不过闸门就不算完成**,不得跳过、不得留红给下一轮。
### 构建上下文完整性(M1 Dockerfile 教训)
`docker build` **不在 pytest/ruff 闸门里**——M1 删了 `alembic_location/poo` 后忘了同步 `Dockerfile``COPY`,单元闸门全绿却把坏掉的镜像构建一路漏到 release tag。所以:
- 任务**删除 / 移动 / 重命名文件或目录**时,必须 grep 构建清单是否还在引用它们:`Dockerfile`(尤其 `COPY` 源)、`docker/``*.ini`、CI workflow、`requirements*.txt` 等。
- 已有回归测试 `tests/test_deployment.py::test_dockerfile_copy_sources_exist` 守"Dockerfile `COPY` 源必须存在于构建上下文";新增 / 改动 `COPY` 时确保它仍覆盖得到。
- Reviewer 审"删 / 移文件"类任务时,**必须顺带核对构建清单引用**,把它当 acceptance 的一部分。
## 每轮简报(`review-notes/` ## 每轮简报(`review-notes/`
每轮工作都要在 `review-notes/` 下产出**中文简报**。该目录**已在 `.gitignore` 忽略**,纯本地、不入库——它是 agent 之间和与人之间的交接载体,不是仓库产物。 每轮工作都要在 `review-notes/` 下产出**中文简报**。该目录**已在 `.gitignore` 忽略**,纯本地、不入库——它是 agent 之间和与人之间的交接载体,不是仓库产物。
@@ -85,7 +116,9 @@ python scripts/export_openapi.py && git diff --exit-code openapi/ # 改了路
## Commit 规范(重点) ## Commit 规范(重点)
### 分支 ### 分支
- 每个 milestone/feature 一个分支(如 `feature/m1-db-consolidation`),**不在 `main` 上直接提交**。 - **本仓库是个人单用户项目:默认直接在 `main` 上开发**,不强制 feature 分支,**直接提交并 push 到 `main` 是允许的(无需开 PR**。
- 仍保持**每个任务一个干净 commit**message 前缀任务/里程碑 ID)。改动较大想隔离时可临时开分支,用完**快进合并**回 `main`(保持线性历史),非必需。
- 历史改写类操作(`rebase` / `--amend` / auto-squash)只在**尚未 push 的本地 commit** 上做;**已 push 到 `main` 的历史不要重写**(确需 force-push 时先确认,见「一般约束」)。
### 一轮实现完成(用户确认「实现完成」后) ### 一轮实现完成(用户确认「实现完成」后)
- 准备好**这一轮的 commit message** 并提交,作为本轮的 **base commit** - 准备好**这一轮的 commit message** 并提交,作为本轮的 **base commit**
@@ -97,12 +130,13 @@ python scripts/export_openapi.py && git diff --exit-code openapi/ # 改了路
- 每次提交前**自检**`git log -1 --format=%B` 的输出**不得包含** `Co-authored-by`(大小写不限)。若发现,立即 `git commit --amend` 去掉后再继续。 - 每次提交前**自检**`git log -1 --format=%B` 的输出**不得包含** `Co-authored-by`(大小写不限)。若发现,立即 `git commit --amend` 去掉后再继续。
### Review 后返工 ### Review 后返工
- 返工产生的提交**一律用 fixup**,指向本轮对应的 base commit**不写新的独立 message** - **自动化 orchestration 模式内**的 review 返工:**一律用 fixup**,指向本轮对应的 base commit**不写新的独立 message**
```bash ```bash
git add -A git add -A
git commit --fixup=<base-commit-sha> git commit --fixup=<base-commit-sha>
``` ```
- 多轮返工就多个 `fixup!` 提交,都指向同一个 base commit。 - 多轮返工就多个 `fixup!` 提交,都指向同一个 base commit;收尾时 auto-squash(见下)
- **边界——什么时候不走 fixup**:**事后另起的独立盲审 / 对抗复审**那一轮,性质等同"**人工走查后提修改意见**",**不算自动化链内的返工**——它的修改用**各自独立的 commit**,不 fixup 到旧 base。判据:这轮返工是否在**同一条自动化 implement→review 链**里?是 → `fixup`;是事后另起的独立审计 → 独立 commit。
### 本轮 / feature 收尾(用户确认收尾后) ### 本轮 / feature 收尾(用户确认收尾后)
- 用 **auto-squash** 把所有 `fixup!` 合并进各自目标,保证**一个 feature 一个干净 commit** - 用 **auto-squash** 把所有 `fixup!` 合并进各自目标,保证**一个 feature 一个干净 commit**
@@ -113,7 +147,17 @@ python scripts/export_openapi.py && git diff --exit-code openapi/ # 改了路
- autosquash **改写历史**:仅在 push / 开 PR **之前**做。若该分支已 push,需要 force-push——属对外操作,**先取得用户确认再做**。 - autosquash **改写历史**:仅在 push / 开 PR **之前**做。若该分支已 push,需要 force-push——属对外操作,**先取得用户确认再做**。
### 一般约束 ### 一般约束
- commit / push 只在用户要求时进行;push、force-push、开/改 PR 等对外操作先确认 - **个人单用户仓库:直接 commit push 到 `main` 已获授权**——在校验闸门全绿、一轮工作完成时即可提交 / 推送,无需逐次征求同意
- 仍需**先取得用户确认**的操作:**force-push / 改写已推送历史**,以及**打 tag**(会触发镜像 CI / 对外发布;且打 tag 前须按下方「发版前置走查」真跑一次 `docker build`)。
## 发版前置走查(打 tag 前必做)
单元闸门绿 ≠ 真的能跑、能构建、能用。M1 出过"绿了但 docker 构建坏了"的事故,所以**打版本 tag(触发镜像 CI)之前**,除了 `pytest` / `ruff` 全绿,还要:
- **真起 app**:迁移(`python -m scripts.run_migrations`)→ `uvicorn app.main:app ...`,确认能正常启动、关键路由不 500。
- **真跑镜像构建**:本地 `docker build`(多阶段就跑完整条),确认构建通过、`COPY` 源都在。
- **关键功能人工瞄一眼**:尤其前端 / 可视化类(M2 的热力图、首页地图)——自动闸门判断不了"渲染对不对、UX 顺不顺",这部分**靠看跑起来的 app,不靠读代码**。
- 上述任一不过 → **不打 tag**。tag 一旦 push 会触发 docker 镜像 CI / 对外发布,属对外操作,**先确认**。
## 数据安全红线(不可违反) ## 数据安全红线(不可违反)
+20 -4
View File
@@ -1,3 +1,20 @@
# Stage 1: build the React SPA.
# Pin to the native build host ($BUILDPLATFORM) so the Node/V8 build never runs
# under QEMU during multi-arch builds — emulated Node crashes V8's baseline JIT
# (SIGTRAP / exit 133 on `npm ci`). The dist/ output is static JS/CSS, i.e.
# architecture-independent, so building it once and COPYing it into each
# target-arch runtime stage is both correct and avoids the emulator entirely.
FROM --platform=$BUILDPLATFORM node:22-slim AS frontend-build
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# Stage 2: python runtime (no node)
FROM python:3.12-slim FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
@@ -11,15 +28,14 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app COPY app ./app
COPY alembic_app ./alembic_app COPY alembic_app ./alembic_app
COPY alembic_app.ini ./ COPY alembic_app.ini ./
COPY alembic_location ./alembic_location
COPY alembic_location.ini ./
COPY alembic_poo ./alembic_poo
COPY alembic_poo.ini ./
COPY scripts ./scripts COPY scripts ./scripts
COPY docker ./docker COPY docker ./docker
COPY README.md ./ COPY README.md ./
RUN mkdir -p /app/data RUN mkdir -p /app/data
# Copy the built SPA dist from the frontend-build stage
COPY --from=frontend-build /frontend/dist ./frontend/dist
EXPOSE 8000 EXPOSE 8000
ENTRYPOINT ["/app/docker/entrypoint.sh"] ENTRYPOINT ["/app/docker/entrypoint.sh"]
+130 -20
View File
@@ -4,9 +4,9 @@
当前系统已经包含: 当前系统已经包含:
- FastAPI Web 应用与服务端模板页面 - FastAPI Web 应用React SPA 前端 + JSON API
- SQLite + SQLAlchemy + Alembic 的单库结构 - SQLite + SQLAlchemy + Alembic 的单库结构
- username/password + server-side session 鉴权 - username/password + server-side session 鉴权(含登录加固,见下文)
- runtime config 页面与 app DB 持久化 - runtime config 页面与 app DB 持久化
- public IPv4 monitor、历史持久化与定时检查 - public IPv4 monitor、历史持久化与定时检查
- SMTP 配置、测试发信与 public IPv4 changed 邮件通知 - SMTP 配置、测试发信与 public IPv4 changed 邮件通知
@@ -47,11 +47,13 @@ python -m scripts.run_migrations
主要目录如下: 主要目录如下:
- `app/`: FastAPI 应用代码 - `app/`: FastAPI 应用代码(包含 JSON API、业务服务、数据模型)
- `frontend/`: React SPA 前端(Vite + React + TypeScript + Mantine
- `alembic_app/`: App DB 的 Alembic migration 环境(同时管理 `location` / `poo_records` 表) - `alembic_app/`: App DB 的 Alembic migration 环境(同时管理 `location` / `poo_records` 表)
- `tests/`: pytest 测试 - `tests/`: pytest 测试
- `docs/`: 当前系统说明文档 - `docs/`: 当前系统说明文档
- `scripts/`: 辅助脚本,例如 OpenAPI 导出 - `scripts/`: 辅助脚本,例如 OpenAPI 导出
- `openapi/`: OpenAPI schema 静态产物(`openapi.json` / `openapi.yaml`),纳入版本控制
## 依赖管理 ## 依赖管理
@@ -112,11 +114,62 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
启动后可访问: 启动后可访问:
- 应用首页:`http://localhost:8000/` - 应用首页React SPA`http://localhost:8000/`
- 健康检查:`http://localhost:8000/status` - 健康检查:`http://localhost:8000/status`
- Swagger UI`http://localhost:8000/docs` - Swagger UI`http://localhost:8000/docs`
- ReDoc`http://localhost:8000/redoc` - ReDoc`http://localhost:8000/redoc`
## 前端 v2React SPA
M2 用 React SPA 取代了原有 Jinja 服务端模板,由 FastAPI 同源托管(同一容器、同一 origin)。
### 技术栈
- **Vite + React + TypeScript + Mantine**(组件库)
- **TanStack Query**(数据请求/缓存)
- **Leaflet / react-leaflet**(地图与热力图)
- **openapi-typescript + openapi-fetch**(类型化 API client,由 `openapi/openapi.json` 生成)
### 本地开发(前端)
前端开发服务器会把 `/api``/location``/poo``/public-ip``/homeassistant``/ticktick``/status` 等路径代理到后端 FastAPI`:8000`)。
```bash
cd frontend
npm install
npm run dev # 启动 Vite dev server(默认 :5173),代理后端
```
### 构建
```bash
cd frontend
npm run build # 产出 frontend/dist
```
FastAPI 启动时若 `frontend/dist/index.html` 存在,则自动挂载该目录,并对非 `/api` 路径做 SPA fallback(返回 `index.html`)。该路径可通过环境变量 `SPA_DIST_DIR` 覆盖(默认值为 `frontend/dist`,与多阶段 Dockerfile 中 `COPY``/app/frontend/dist` 一致)。
### 类型化 API Client
前端 API client 由后端 OpenAPI schema 自动生成:
```bash
cd frontend
npm run codegen # 从 ../openapi/openapi.json 生成 src/api/schema.d.ts
```
生成物(`src/api/schema.d.ts`)已提交入库,CI 会校验它与 `openapi/openapi.json` 保持同步。
### 前端校验闸门
```bash
cd frontend
npm run lint # ESLint
npm run typecheck # TypeScript 类型检查
npm run test # Vitest 单元测试
npm run build # 构建,确认产出 dist
```
## 数据库与 Alembic ## 数据库与 Alembic
当前使用单一 SQLite 数据库文件: 当前使用单一 SQLite 数据库文件:
@@ -142,9 +195,9 @@ python -m scripts.migrate_legacy_data
- 认证模型:`username/password` - 认证模型:`username/password`
- 会话模型:server-side session + cookie - 会话模型:server-side session + cookie
- 当前主要受保护页面:`/config` - 当前受保护入口:React SPA`/` 等客户端路由)调用 `/api/*` JSON 端点
- 当前公开页面:`/login` - 当前公开页面:`/login`SPA 登录页)
- 当前公开 API现有业务 API 暂未在这一轮统一收口到 auth 下 - 当前公开 API裸 ingestion 端点(`/location/record``/poo/record` 等设备调用端点)暂未收口到 session 保护(M3 再做)
安全实现的当前边界: 安全实现的当前边界:
@@ -152,7 +205,7 @@ python -m scripts.migrate_legacy_data
- session cookie 使用 `HttpOnly` - session cookie 使用 `HttpOnly`
- `Secure` 默认随 `APP_ENV` 切换:非 development 时默认开启 - `Secure` 默认随 `APP_ENV` 切换:非 development 时默认开启
- `SameSite=Lax` - `SameSite=Lax`
- 登录表单和登出表单都有基础 CSRF 防护 - 写请求(POST/PUT/PATCH/DELETE)需携带 `X-CSRF-Token` headerSameSite=Lax + 自定义 header 纵深防御,无需 per-session token 值比对)
首次启动时,如果 `APP_DATABASE_URL` 对应的 auth DB 里还没有用户,应用会使用: 首次启动时,如果 `APP_DATABASE_URL` 对应的 auth DB 里还没有用户,应用会使用:
@@ -166,12 +219,67 @@ python -m scripts.migrate_legacy_data
首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。 首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。
当前前端主要有两条页面路径 React SPA 主要页面路由(客户端路由,均由 FastAPI fallback 到 `index.html`
- `/login` - `/login`:登录页
- `/config` - `/`:首页(地图热力图主视图)
- `/config`:配置页(取代原 Jinja `/config`
- `/records`:记录管理列表页
无论是本地 `host:port` 还是反向代理后的域名访问,登录成功后都使用相对路径跳转到 `/config` 无论是本地 `host:port` 还是反向代理后的域名访问,登录成功后进入 SPA 首页(`/`
## M4 登录加固
M4 在基础鉴权之上叠加了三层防御,详细说明见 [`docs/auth.md`](./docs/auth.md)。
### 防爆破 / 指数退避
登录失败超过 3 次后进入指数退避(`wait = min(900s, 1s × 2^(failures-3))`),期间请求返回 `429 Too Many Requests`(含 `Retry-After` 响应头);成功登录后自动清零。退避按 **client IP****username** 双键取较大值,不会因此永久锁定账号(只是延迟,不是封号)。
- 全局开关:`AUTH_LOGIN_THROTTLE_ENABLED`CONFIG_FIELDS,默认 `true`
- 反代后需要 `AUTH_TRUST_FORWARDED_FOR=true` 才会读 `X-Forwarded-For``.env` 部署级配置,默认 `false`
### CLI 逃生通道
拿到服务器 CLI 权限时,可以在**不依赖任何已存凭据**(无需密码、恢复码)的情况下重置密码、解锁退避、关停 TOTP:
```bash
# 重置密码(不加 --password 则交互式输入,不回显)
python -m scripts.admin_cli reset-password admin
# 解锁退避(被 429 挡住时使用)
python -m scripts.admin_cli unlock --all # 清所有退避行
python -m scripts.admin_cli unlock --ip 1.2.3.4 # 按 IP 清
python -m scripts.admin_cli unlock --username admin # 按 username 清
# TOTP 相关(需要先启用,见下文)
python -m scripts.admin_cli disable-totp admin # 关停 TOTP(零凭据,逃生口)
python -m scripts.admin_cli reissue-totp admin # 重新发放 TOTP secret(打印新 URI
# 查看用户列表
python -m scripts.admin_cli list-admin
```
在 Docker 容器内执行时:
```bash
docker compose exec app python -m scripts.admin_cli <command>
```
### 可选 TOTP 二次验证
admin 可在 React SPA 设置页(`/config`)自选启用 RFC 6238 TOTP
1. 设置页点「启用 TOTP」→ 后端生成 `otpauth://` URI,前端渲染二维码(`qrcode.react`
2. 用 Authenticator App(如 Google Authenticator、Authy)扫码
3. 输入当前 6 位动态码确认 → TOTP 启用
4. 妥善保存一次性展示的 10 个恢复码(格式 `xxxx-xxxx`
启用后,登录需要两步:密码 → 6 位动态码(或恢复码,一次性)。不启用则维持纯密码登录,行为不变。
恢复码丢失时,可用 CLI 逃生:`python -m scripts.admin_cli disable-totp admin`,随后即可纯密码登录。
TOTP issuer 标签(显示在 Authenticator 里)通过 `AUTH_TOTP_ISSUER` 环境变量配置(`.env` 部署级),默认回退 `app_name`
## Config 持久化 ## Config 持久化
@@ -230,8 +338,8 @@ python -m scripts.migrate_legacy_data
当前系统已经提供最小可用的 SMTP 能力: 当前系统已经提供最小可用的 SMTP 能力:
- SMTP 配置可在 `/config` 页面填写并保存到 `app_config` - SMTP 配置可在 React SPA `/config` 页面填写并保存到 `app_config`(通过 `PUT /api/config`
- 可通过 config 页面发送测试邮件 - 可通过 config 页面发送测试邮件`POST /api/config/smtp/test`
- 邮件 `From` 头支持显示名,例如 `Home Automation <sender@example.com>` - 邮件 `From` 头支持显示名,例如 `Home Automation <sender@example.com>`
当前 SMTP 配置项包括: 当前 SMTP 配置项包括:
@@ -283,18 +391,20 @@ python scripts/export_openapi.py
当前 Compose 分成两层: 当前 Compose 分成两层:
- `docker-compose.yml`:默认使用 registry image,适合部署 / 生产拉取 - `docker-compose.yml`:默认使用 registry image,适合部署 / 生产拉取(暴露 8881
- `docker-compose.override.yml`仅为本地开发追加 `build: .` - `docker-compose.dev.yml`:本地开发显式叠加层——追加 `build: .`、独立 project /
容器名(`-dev` 后缀)、暴露 8001,并把 DB 指向挂载的 `./data` 副本,可与生产栈在同一台机器上并存
本地开发启动方式: 本地开发启动方式(显式叠加 dev 层)
```bash ```bash
docker compose up -d --build docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
``` ```
上面的命令会自动叠加 `docker-compose.override.yml`,因此本地仍然会按当前工作目录重新 build。 dev 层刻意不沿用 `docker-compose.override.yml` 这种会被 `docker compose up` 自动叠加的文件名,
因此默认的 `docker compose up` 只用生产基础文件,不会把开发端口 / 配置误带到生产。
如果要按生产方式直接从 registry 拉取并启动,显式只使用基础 compose 文件: 如果要按生产方式直接从 registry 拉取并启动,使用基础 compose 文件:
```bash ```bash
docker compose -f docker-compose.yml pull docker compose -f docker-compose.yml pull
+2 -1
View File
@@ -6,7 +6,8 @@ from sqlalchemy import engine_from_config, pool
from app.config import get_settings from app.config import get_settings
from app.db import Base from app.db import Base
from app.models.config import AppConfigEntry # noqa: F401 from app.models.config import AppConfigEntry # noqa: F401
from app.models.auth import AuthSession, AuthUser # noqa: F401 from app.models.auth import AuthSession, AuthUser, RecoveryCode # noqa: F401
from app.models.auth_throttle import LoginThrottle # noqa: F401
from app.models.public_ip import PublicIPHistory, PublicIPState # noqa: F401 from app.models.public_ip import PublicIPHistory, PublicIPState # noqa: F401
from app.models.location import Location # noqa: F401 from app.models.location import Location # noqa: F401
from app.models.poo import PooRecord # noqa: F401 from app.models.poo import PooRecord # noqa: F401
@@ -0,0 +1,43 @@
"""add auth_login_throttle table for exponential back-off throttling
Revision ID: 20260621_07_auth_login_throttle
Revises: 20260611_06_merge_location_poo_tables
Create Date: 2026-06-21 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260621_07_auth_login_throttle"
down_revision: Union[str, None] = "20260611_06_merge_location_poo_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"auth_login_throttle",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("key", sa.String(length=255), nullable=False),
sa.Column("scope", sa.String(length=16), nullable=False),
sa.Column("failures", sa.Integer(), nullable=False),
sa.Column("first_failed_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_failed_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("next_allowed_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("scope", "key", name="uq_auth_login_throttle_scope_key"),
)
op.create_index(
"ix_auth_login_throttle_scope_key",
"auth_login_throttle",
["scope", "key"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_auth_login_throttle_scope_key", table_name="auth_login_throttle")
op.drop_table("auth_login_throttle")
+61
View File
@@ -0,0 +1,61 @@
"""add TOTP fields to auth_users and create auth_recovery_code table
Revision ID: 20260621_08_totp
Revises: 20260621_07_auth_login_throttle
Create Date: 2026-06-21 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260621_08_totp"
down_revision: Union[str, None] = "20260621_07_auth_login_throttle"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add totp_secret (nullable) to auth_users — existing rows get NULL, which is correct.
op.add_column("auth_users", sa.Column("totp_secret", sa.String(length=64), nullable=True))
# Add totp_enabled (NOT NULL) with server_default="0" so existing rows default to false.
op.add_column(
"auth_users",
sa.Column(
"totp_enabled",
sa.Boolean(),
nullable=False,
server_default="0",
),
)
# Create auth_recovery_code table for one-time TOTP recovery codes.
op.create_table(
"auth_recovery_code",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("code_hash", sa.String(length=255), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["auth_users.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_auth_recovery_code_user_id"),
"auth_recovery_code",
["user_id"],
unique=False,
)
def downgrade() -> None:
# Drop auth_recovery_code table first (has FK to auth_users).
op.drop_index(op.f("ix_auth_recovery_code_user_id"), table_name="auth_recovery_code")
op.drop_table("auth_recovery_code")
# Drop the two TOTP columns from auth_users.
# Use batch_alter_table for SQLite compatibility (alembic's portable column-drop path).
with op.batch_alter_table("auth_users") as batch_op:
batch_op.drop_column("totp_enabled")
batch_op.drop_column("totp_secret")
View File
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
from app.config import Settings, get_settings
from app.dependencies import get_app_settings, get_db
from app.schemas.config import (
ConfigField,
ConfigResponse,
ConfigSection,
ConfigUpdateRequest,
ConfigUpdateResponse,
SmtpTestResponse,
)
from app.services.auth import AuthenticatedSession
from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates
from app.services.email import EmailConfigurationError, EmailDeliveryError, send_smtp_test_email
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["api-config"])
def _sections_from_raw(sections_raw: list[dict]) -> list[ConfigSection]:
result = []
for section in sections_raw:
fields = [ConfigField(**f) for f in section["fields"]]
result.append(ConfigSection(name=section["name"], fields=fields))
return result
@router.get("/config", response_model=ConfigResponse)
def get_config(
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
_auth: AuthenticatedSession = Depends(require_session),
) -> ConfigResponse:
"""Return all configuration sections. Secret field values are masked (empty string)."""
sections_raw = build_config_sections(db, settings)
return ConfigResponse(sections=_sections_from_raw(sections_raw))
@router.put("/config", response_model=ConfigUpdateResponse)
def put_config(
body: ConfigUpdateRequest,
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> ConfigUpdateResponse:
"""
Save configuration updates.
- Blank secret value keeps the existing stored value (no change).
- Invalid values return 422 and nothing is written to the database.
"""
try:
save_config_updates(db, body.updates, settings)
except ConfigSaveError as exc:
logger.warning("Rejected config update via API: %s", exc)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="invalid config submission",
) from exc
# Re-read settings after save (save_config_updates clears the settings cache)
refreshed_settings = get_settings()
sections_raw = build_config_sections(db, refreshed_settings)
return ConfigUpdateResponse(sections=_sections_from_raw(sections_raw))
@router.post(
"/config/smtp/test",
responses={
200: {"model": SmtpTestResponse},
400: {"model": SmtpTestResponse},
502: {"model": SmtpTestResponse},
},
)
def post_smtp_test(
settings: Settings = Depends(get_app_settings),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> JSONResponse:
"""
Send a test SMTP email using the current runtime settings.
Returns a structured result indicating success or the category of failure.
Three possible outcomes:
- 200 { "result": "success", "message": ... }
- 400 { "result": "config-error", "message": ... } (EmailConfigurationError)
- 502 { "result": "failed", "message": ... } (EmailDeliveryError)
SMTP credentials are never echoed in the response.
"""
try:
send_smtp_test_email(settings)
except EmailConfigurationError as exc:
logger.warning("SMTP test rejected due to configuration: %s", exc)
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"result": "config-error", "message": str(exc)},
)
except EmailDeliveryError as exc:
logger.warning("SMTP test delivery failed: %s", exc)
return JSONResponse(
status_code=status.HTTP_502_BAD_GATEWAY,
content={"result": "failed", "message": str(exc)},
)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={"result": "success", "message": "Test email sent successfully."},
)
+275
View File
@@ -0,0 +1,275 @@
from __future__ import annotations
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
from sqlalchemy import desc, select
from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
from app.dependencies import get_db
from app.models.location import Location
from app.models.poo import PooRecord
from app.models.public_ip import PublicIPHistory, PublicIPState
from app.schemas.data import (
LocationRecord,
LocationUpdateRequest,
LocationsResponse,
PooRecord as PooRecordSchema,
PooResponse,
PooUpdateRequest,
PublicIPHistorySchema,
PublicIPResponse,
PublicIPStateSchema,
)
from app.services.auth import AuthenticatedSession
from app.services.location import delete_location, update_location
from app.services.poo import delete_poo_record, update_poo_record
router = APIRouter(prefix="/api", tags=["api-data"])
@router.get("/locations", response_model=LocationsResponse)
def get_locations(
limit: int = Query(default=1000, ge=1, le=5000),
offset: int = Query(default=0, ge=0),
start: str | None = Query(default=None),
end: str | None = Query(default=None),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> LocationsResponse:
"""
Return location records with optional time-window filtering and pagination.
- ``start`` / ``end`` are ISO8601 strings; filtering is **inclusive** on both bounds.
- Results are ordered by ``datetime`` ascending.
- ``limit`` is capped at 5000 to prevent full-table exports.
"""
stmt = select(Location)
if start is not None:
stmt = stmt.where(Location.datetime >= start)
if end is not None:
stmt = stmt.where(Location.datetime <= end)
stmt = stmt.order_by(Location.datetime).offset(offset).limit(limit)
rows = db.execute(stmt).scalars().all()
items = [
LocationRecord(
person=row.person,
datetime=row.datetime,
latitude=row.latitude,
longitude=row.longitude,
altitude=row.altitude,
)
for row in rows
]
return LocationsResponse(items=items, limit=limit, offset=offset)
@router.get("/poo", response_model=PooResponse)
def get_poo(
limit: int = Query(default=100, ge=1, le=1000),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> PooResponse:
"""
Return poo records ordered by timestamp descending (most recent first).
``limit`` is capped at 1000 to prevent full-table exports.
"""
stmt = (
select(PooRecord)
.order_by(desc(PooRecord.timestamp))
.offset(offset)
.limit(limit)
)
rows = db.execute(stmt).scalars().all()
items = [
PooRecordSchema(
timestamp=row.timestamp,
status=row.status,
latitude=row.latitude,
longitude=row.longitude,
)
for row in rows
]
return PooResponse(items=items, limit=limit, offset=offset)
@router.get("/public-ip", response_model=PublicIPResponse)
def get_public_ip(
limit: int = Query(default=100, ge=1, le=1000),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> PublicIPResponse:
"""
Return the current public IP state and recent history.
- ``state`` is ``null`` if no IP check has been performed yet.
- ``history`` is ordered by ``observed_at`` descending (most recent first).
- ``limit`` applies to the history list and is capped at 1000.
"""
state_row = db.execute(
select(PublicIPState).where(PublicIPState.id == 1).limit(1)
).scalar_one_or_none()
history_rows = db.execute(
select(PublicIPHistory).order_by(desc(PublicIPHistory.observed_at)).limit(limit)
).scalars().all()
state = PublicIPStateSchema.model_validate(state_row) if state_row is not None else None
history = [PublicIPHistorySchema.model_validate(row) for row in history_rows]
return PublicIPResponse(state=state, history=history)
# ---------------------------------------------------------------------------
# PATCH /api/locations/{person}/{datetime}
# ---------------------------------------------------------------------------
@router.patch("/locations/{person}/{datetime}", response_model=LocationRecord)
def patch_location(
person: str,
datetime: str,
body: LocationUpdateRequest = Body(default=LocationUpdateRequest()),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> LocationRecord:
"""
Update the non-PK fields of a single location record.
- ``person`` and ``datetime`` identify the row (composite PK) and are immutable.
- Only ``latitude``, ``longitude``, and ``altitude`` may be updated.
- Omitted body fields are left unchanged.
- Returns **404** if the PK does not exist.
"""
row = update_location(
db,
person,
datetime,
latitude=body.latitude,
longitude=body.longitude,
altitude=body.altitude,
)
if row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="location record not found",
)
return LocationRecord(
person=row.person,
datetime=row.datetime,
latitude=row.latitude,
longitude=row.longitude,
altitude=row.altitude,
)
# ---------------------------------------------------------------------------
# DELETE /api/locations/{person}/{datetime}
# ---------------------------------------------------------------------------
@router.delete(
"/locations/{person}/{datetime}",
status_code=status.HTTP_204_NO_CONTENT,
response_model=None,
)
def delete_location_record(
person: str,
datetime: str,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> None:
"""
Delete the single location record identified by its composite PK.
- Exactly one row is deleted; **404** if the PK does not exist.
- No batch delete / truncate path is available.
"""
deleted = delete_location(db, person, datetime)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="location record not found",
)
# ---------------------------------------------------------------------------
# PATCH /api/poo/{timestamp}
# ---------------------------------------------------------------------------
@router.patch("/poo/{timestamp}", response_model=PooRecordSchema)
def patch_poo(
timestamp: str,
body: PooUpdateRequest = Body(default=PooUpdateRequest()),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> PooRecordSchema:
"""
Update the non-PK fields of a single poo record.
- ``timestamp`` is the PK and is immutable.
- Only ``status``, ``latitude``, and ``longitude`` may be updated.
- Omitted body fields are left unchanged.
- Returns **404** if the PK does not exist.
"""
row = update_poo_record(
db,
timestamp,
status=body.status,
latitude=body.latitude,
longitude=body.longitude,
)
if row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="poo record not found",
)
return PooRecordSchema(
timestamp=row.timestamp,
status=row.status,
latitude=row.latitude,
longitude=row.longitude,
)
# ---------------------------------------------------------------------------
# DELETE /api/poo/{timestamp}
# ---------------------------------------------------------------------------
@router.delete(
"/poo/{timestamp}",
status_code=status.HTTP_204_NO_CONTENT,
response_model=None,
)
def delete_poo(
timestamp: str,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> None:
"""
Delete the single poo record identified by its PK.
- Exactly one row is deleted; **404** if the PK does not exist.
- No batch delete / truncate path is available.
"""
deleted = delete_poo_record(db, timestamp)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="poo record not found",
)
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
from fastapi import Depends, Header, HTTPException, status
from app.dependencies import get_current_auth_session
from app.services.auth import AuthenticatedSession
def require_session(
auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> AuthenticatedSession:
if auth is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="authentication required",
)
return auth
def require_csrf(
_auth: AuthenticatedSession = Depends(require_session),
x_csrf_token: str | None = Header(default=None, alias="X-CSRF-Token"),
) -> None:
if not x_csrf_token:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="missing CSRF token",
)
+324
View File
@@ -0,0 +1,324 @@
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
from app.config import Settings
from app.dependencies import get_app_settings, get_db
from app.schemas.session import (
LoginRequest,
PasswordChangeRequest,
SessionResponse,
SessionUser,
)
from app.schemas.totp import (
TotpDisableRequest,
TotpEnableRequest,
TotpSetupResponse,
TotpStatusResponse,
)
from app.services.auth import (
AuthPasswordChangeError,
AuthenticatedSession,
authenticate_user,
change_password,
create_session,
revoke_session,
)
from app.services import login_throttle
from app.services import totp as totp_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["api-session"])
def _build_session_response(auth: AuthenticatedSession) -> SessionResponse:
return SessionResponse(
user=SessionUser(
username=auth.user.username,
force_password_change=auth.user.force_password_change,
),
csrf_token=auth.session.csrf_token,
)
@router.get("/session", response_model=SessionResponse)
def get_session(
auth: AuthenticatedSession = Depends(require_session),
) -> SessionResponse:
"""Return the current session user and CSRF token. Returns 401 if not authenticated."""
return _build_session_response(auth)
def _get_client_ip(request: Request, *, trust_forwarded_for: bool) -> str:
"""Extract the client IP address from the request.
When ``trust_forwarded_for`` is True (reverse-proxy deployments) the
left-most value from the ``X-Forwarded-For`` header is used. Otherwise the
direct socket IP (``request.client.host``) is used.
"""
if trust_forwarded_for:
xff = request.headers.get("X-Forwarded-For", "")
if xff:
return xff.split(",")[0].strip()
if request.client is not None:
return request.client.host
return "unknown"
@router.post("/auth/login", response_model=SessionResponse)
def post_login(
body: LoginRequest,
request: Request,
response: Response,
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
) -> SessionResponse:
"""
Authenticate with username and password.
On success, sets an HttpOnly session cookie and returns the session user + CSRF token.
On failure, returns 401 with no cookie set.
Repeated failures trigger exponential back-off (429 + Retry-After).
No X-CSRF-Token required (unauthenticated endpoint).
"""
client_ip = _get_client_ip(request, trust_forwarded_for=settings.auth_trust_forwarded_for)
# --- Throttle check (before any password verification) ---
if settings.auth_login_throttle_enabled:
wait_seconds = login_throttle.check_and_get_wait(
db, ip=client_ip, username=body.username
)
if wait_seconds > 0:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="too many failed login attempts; please try again later",
headers={"Retry-After": str(wait_seconds)},
)
# --- Password verification ---
user = authenticate_user(db, username=body.username, password=body.password)
if user is None:
if settings.auth_login_throttle_enabled:
login_throttle.register_failure(db, ip=client_ip, username=body.username)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid username or password",
)
# --- TOTP second-factor check (only when TOTP is enabled for the user) ---
if user.totp_enabled:
if not body.totp_code:
# Password correct but no TOTP code supplied: signal the front-end to
# prompt for the second factor. Do NOT issue a session.
# Deliberate: this is a normal two-step protocol step from a legitimate
# user — we do NOT register a throttle failure here. The attacker must
# already have the correct password to reach this branch, and counting
# each normal first-step as a failure would risk locking out the real
# admin on every login attempt.
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"totp_required": True},
)
# TOTP code supplied — verify against time-based code or a recovery code.
totp_ok = totp_service.verify_totp_code(user, body.totp_code)
if not totp_ok:
totp_ok = totp_service.verify_recovery_code(db, user=user, code=body.totp_code)
if not totp_ok:
# Second-factor failure is an active attack signal — register failure
# so that repeated wrong TOTP/recovery-code guesses trigger back-off.
if settings.auth_login_throttle_enabled:
login_throttle.register_failure(db, ip=client_ip, username=body.username)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid username or password",
)
# --- Success: clear back-off state and issue session ---
if settings.auth_login_throttle_enabled:
login_throttle.clear(db, ip=client_ip, username=body.username)
auth_session, raw_token = create_session(db, user=user, settings=settings)
logger.info("Created API authenticated session for user '%s'", user.username)
response.set_cookie(
key=settings.auth_session_cookie_name,
value=raw_token,
max_age=settings.auth_session_ttl_hours * 3600,
httponly=True,
secure=settings.auth_cookie_secure,
samesite="lax",
path="/",
)
auth = AuthenticatedSession(user=user, session=auth_session)
return _build_session_response(auth)
@router.post("/auth/logout")
def post_logout(
response: Response,
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> Response:
"""
Revoke the current session and clear the session cookie.
Requires authentication and X-CSRF-Token header.
Returns 204 No Content.
"""
revoke_session(db, auth_session=auth.session)
logger.info("Revoked API authenticated session for user '%s'", auth.user.username)
no_content = Response(status_code=status.HTTP_204_NO_CONTENT)
no_content.delete_cookie(settings.auth_session_cookie_name, path="/")
return no_content
@router.post("/auth/password")
def post_change_password(
body: PasswordChangeRequest,
db: Session = Depends(get_db),
auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> Response:
"""
Change the current user's password.
Requires authentication and X-CSRF-Token header.
On AuthPasswordChangeError returns 400 with a generic message.
On success, force_password_change becomes False (handled by the service).
Returns 204 No Content.
"""
try:
change_password(
db,
user=auth.user,
current_password=body.current_password,
new_password=body.new_password,
confirm_password=body.confirm_password,
)
except AuthPasswordChangeError as exc:
logger.info(
"Rejected password change for user '%s': %s",
auth.user.username,
exc,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="password change failed",
) from exc
logger.info("Password updated for user '%s'", auth.user.username)
return Response(status_code=status.HTTP_204_NO_CONTENT)
# ---------------------------------------------------------------------------
# TOTP endpoints (M4-T05)
# ---------------------------------------------------------------------------
@router.post("/auth/totp/setup", response_model=TotpSetupResponse)
def post_totp_setup(
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> TotpSetupResponse:
"""
Generate a new pending TOTP secret, otpauth URI, and one-time recovery codes.
The secret is stored in the DB but TOTP is NOT yet enabled (totp_enabled stays
False until the user confirms with POST /api/auth/totp/enable).
Recovery codes are returned here as plaintext exactly once; their Argon2 hashes
are persisted immediately so enable only needs to flip the enabled flag.
Repeating this call replaces any prior pending secret and regenerates codes.
Requires: session cookie + X-CSRF-Token.
"""
secret, otpauth_uri, recovery_codes = totp_service.setup(
db,
user=auth.user,
issuer=settings.effective_totp_issuer,
)
return TotpSetupResponse(
secret=secret,
otpauth_uri=otpauth_uri,
recovery_codes=recovery_codes,
)
@router.post("/auth/totp/enable", status_code=status.HTTP_204_NO_CONTENT)
def post_totp_enable(
body: TotpEnableRequest,
db: Session = Depends(get_db),
auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> Response:
"""
Enable TOTP by confirming with the current 6-digit code from the authenticator app.
Requires a prior call to POST /api/auth/totp/setup (so that a pending secret
exists). On success, totp_enabled becomes True.
Returns 400 if the code is wrong or there is no pending secret.
Requires: session cookie + X-CSRF-Token.
"""
ok = totp_service.enable(db, user=auth.user, code=body.code)
if not ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="invalid TOTP code or no pending setup",
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/auth/totp/disable", status_code=status.HTTP_204_NO_CONTENT)
def post_totp_disable(
body: TotpDisableRequest,
db: Session = Depends(get_db),
auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> Response:
"""
Disable TOTP. The caller must provide exactly one of:
- ``password``: the user's current login password, OR
- ``code``: the current 6-digit TOTP code.
On success: totp_enabled=False, totp_secret cleared, all recovery codes deleted.
Returns 400 if neither credential matches or neither is provided.
Requires: session cookie + X-CSRF-Token.
"""
ok = totp_service.disable(
db,
user=auth.user,
password=body.password,
code=body.code,
)
if not ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="invalid credential; provide a valid password or TOTP code",
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/auth/totp", response_model=TotpStatusResponse)
def get_totp_status(
auth: AuthenticatedSession = Depends(require_session),
) -> TotpStatusResponse:
"""
Return the current TOTP status for the authenticated user.
Response contains only ``{"enabled": bool}``.
Secret and recovery codes are NEVER returned here.
Requires: session cookie only (no CSRF — read-only).
"""
return TotpStatusResponse(enabled=auth.user.totp_enabled)
-234
View File
@@ -1,234 +0,0 @@
import logging
from pathlib import Path
from fastapi import APIRouter, Depends, Form, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.config import Settings
from app.dependencies import get_app_settings, get_db, get_current_auth_session
from app.services.auth import (
AuthenticatedSession,
authenticate_user,
change_password,
create_session,
AuthPasswordChangeError,
issue_login_csrf_token,
revoke_session,
validate_csrf_token,
)
from app.services.config_page import build_config_sections, is_ticktick_oauth_ready
logger = logging.getLogger(__name__)
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
router = APIRouter(tags=["auth"])
LOGIN_CSRF_COOKIE_NAME = "login_csrf"
@router.get("/login", response_class=HTMLResponse)
def login_page(
request: Request,
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is not None:
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
csrf_token = issue_login_csrf_token()
response = templates.TemplateResponse(
request,
"login.html",
{
"app_name": settings.app_name,
"app_env": settings.app_env,
"csrf_token": csrf_token,
"error_message": None,
},
)
_set_login_csrf_cookie(response, settings=settings, token=csrf_token)
return response
@router.post("/login", response_class=HTMLResponse)
def login_submit(
request: Request,
username: str = Form(),
password: str = Form(),
csrf_token: str = Form(),
session: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
) -> Response:
cookie_csrf_token = request.cookies.get(LOGIN_CSRF_COOKIE_NAME)
if not validate_csrf_token(expected=cookie_csrf_token, actual=csrf_token):
logger.warning("Rejected login attempt due to CSRF validation failure")
return _render_login_error(
request,
settings=settings,
status_code=status.HTTP_400_BAD_REQUEST,
error_message="invalid login request",
)
user = authenticate_user(session, username=username, password=password)
if user is None:
return _render_login_error(
request,
settings=settings,
status_code=status.HTTP_401_UNAUTHORIZED,
error_message="invalid username or password",
)
auth_session, raw_token = create_session(session, user=user, settings=settings)
response = RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(LOGIN_CSRF_COOKIE_NAME, path="/login")
response.set_cookie(
key=settings.auth_session_cookie_name,
value=raw_token,
max_age=settings.auth_session_ttl_hours * 3600,
httponly=True,
secure=settings.auth_cookie_secure,
samesite="lax",
path="/",
)
logger.info("Created authenticated session for user '%s'", user.username)
return response
@router.post("/config/change-password", response_class=HTMLResponse)
def change_password_submit(
request: Request,
current_password: str = Form(),
new_password: str = Form(),
confirm_password: str = Form(),
csrf_token: str = Form(),
session: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
if not validate_csrf_token(expected=current_auth.session.csrf_token, actual=csrf_token):
logger.warning("Rejected password change attempt due to CSRF validation failure")
return _render_config_page(
request,
settings=settings,
auth_db_session=session,
current_auth=current_auth,
status_code=status.HTTP_400_BAD_REQUEST,
password_change_error="invalid password change request",
)
try:
change_password(
session,
user=current_auth.user,
current_password=current_password,
new_password=new_password,
confirm_password=confirm_password,
)
except AuthPasswordChangeError as exc:
logger.info(
"Rejected password change for user '%s': %s",
current_auth.user.username,
exc,
)
return _render_config_page(
request,
settings=settings,
auth_db_session=session,
current_auth=current_auth,
status_code=status.HTTP_400_BAD_REQUEST,
password_change_error="password change failed",
)
logger.info("Password updated for user '%s'", current_auth.user.username)
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/logout")
def logout(
request: Request,
csrf_token: str = Form(),
session: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> RedirectResponse:
if current_auth is not None and validate_csrf_token(
expected=current_auth.session.csrf_token, actual=csrf_token
):
revoke_session(session, auth_session=current_auth.session)
logger.info("Revoked authenticated session for user '%s'", current_auth.user.username)
else:
logger.warning("Rejected logout request due to missing session or invalid CSRF token")
response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(settings.auth_session_cookie_name, path="/")
return response
def _render_login_error(
request: Request,
*,
settings: Settings,
status_code: int,
error_message: str,
) -> HTMLResponse:
csrf_token = issue_login_csrf_token()
response = templates.TemplateResponse(
request,
"login.html",
{
"app_name": settings.app_name,
"app_env": settings.app_env,
"csrf_token": csrf_token,
"error_message": error_message,
},
status_code=status_code,
)
_set_login_csrf_cookie(response, settings=settings, token=csrf_token)
return response
def _set_login_csrf_cookie(response: HTMLResponse, *, settings: Settings, token: str) -> None:
response.set_cookie(
key=LOGIN_CSRF_COOKIE_NAME,
value=token,
max_age=1800,
httponly=True,
secure=settings.auth_cookie_secure,
samesite="lax",
path="/login",
)
def _render_config_page(
request: Request,
*,
settings: Settings,
auth_db_session: Session,
current_auth: AuthenticatedSession,
status_code: int,
password_change_error: str | None,
) -> HTMLResponse:
return templates.TemplateResponse(
request,
"config.html",
{
"app_name": settings.app_name,
"app_env": settings.app_env,
"current_username": current_auth.user.username,
"csrf_token": current_auth.session.csrf_token,
"force_password_change": current_auth.user.force_password_change,
"password_change_error": password_change_error,
"config_error": None,
"config_saved": False,
"config_sections": build_config_sections(auth_db_session, settings),
"ticktick_oauth_ready": is_ticktick_oauth_ready(settings),
"ticktick_redirect_uri": settings.ticktick_redirect_uri,
"ticktick_oauth_notice": None,
"ticktick_oauth_error": None,
},
status_code=status_code,
)
-240
View File
@@ -1,240 +0,0 @@
import logging
from pathlib import Path
from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from app.config import Settings, get_settings
from app.dependencies import get_app_settings, get_db, get_current_auth_session
from app.services.auth import AuthenticatedSession
from app.services.config_page import (
ConfigSaveError,
build_config_sections,
is_ticktick_oauth_ready,
save_config_updates,
)
from app.services.email import EmailConfigurationError, EmailDeliveryError, is_smtp_ready, send_smtp_test_email
from sqlalchemy.orm import Session
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
router = APIRouter(tags=["pages"])
logger = logging.getLogger(__name__)
def _ticktick_oauth_notice(status_value: str | None) -> tuple[str | None, str | None]:
if status_value == "success":
return "TickTick authorization completed successfully.", None
if status_value == "invalid-state":
return None, "TickTick authorization failed due to invalid OAuth state. Start the flow again."
if status_value == "invalid-callback":
return None, "TickTick authorization callback was missing required parameters."
if status_value == "failed":
return None, "TickTick authorization failed. Check server logs for the provider response and verify TickTick app credentials and redirect URI."
return None, None
def _smtp_test_notice(status_value: str | None) -> tuple[str | None, str | None]:
if status_value == "success":
return "SMTP test email sent successfully.", None
if status_value == "config-error":
return None, "SMTP test failed. Check required SMTP settings before sending a test email."
if status_value == "failed":
return None, "SMTP test failed. Check saved SMTP settings and server reachability."
return None, None
def _build_config_context(
*,
auth_db_session: Session,
settings: Settings,
current_auth: AuthenticatedSession,
config_saved: bool,
config_error: str | None,
password_change_error: str | None,
ticktick_oauth_notice: str | None,
ticktick_oauth_error: str | None,
smtp_test_notice: str | None,
smtp_test_error: str | None,
) -> dict[str, object]:
return {
"app_name": settings.app_name,
"app_env": settings.app_env,
"current_username": current_auth.user.username,
"csrf_token": current_auth.session.csrf_token,
"force_password_change": current_auth.user.force_password_change,
"password_change_error": password_change_error,
"config_error": config_error,
"config_saved": config_saved,
"config_sections": build_config_sections(auth_db_session, settings),
"ticktick_oauth_ready": is_ticktick_oauth_ready(settings),
"ticktick_redirect_uri": settings.ticktick_redirect_uri,
"ticktick_oauth_notice": ticktick_oauth_notice,
"ticktick_oauth_error": ticktick_oauth_error,
"smtp_test_ready": is_smtp_ready(settings),
"smtp_test_notice": smtp_test_notice,
"smtp_test_error": smtp_test_error,
}
@router.get("/", response_class=HTMLResponse)
def home(
request: Request,
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> RedirectResponse:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/admin", response_class=HTMLResponse)
def admin_redirect(
request: Request,
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> RedirectResponse:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/config", response_class=HTMLResponse)
def config_page(
request: Request,
auth_db_session: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
ticktick_oauth_notice, ticktick_oauth_error = _ticktick_oauth_notice(
request.query_params.get("ticktick_oauth")
)
smtp_test_notice, smtp_test_error = _smtp_test_notice(request.query_params.get("smtp_test"))
context = _build_config_context(
auth_db_session=auth_db_session,
settings=settings,
current_auth=current_auth,
config_saved=request.query_params.get("saved") == "1",
config_error=None,
password_change_error=None,
ticktick_oauth_notice=ticktick_oauth_notice,
ticktick_oauth_error=ticktick_oauth_error,
smtp_test_notice=smtp_test_notice,
smtp_test_error=smtp_test_error,
)
return templates.TemplateResponse(request, "config.html", context)
@router.post("/config", response_class=HTMLResponse)
async def config_submit(
request: Request,
auth_db_session: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
form = await request.form()
csrf_token = form.get("csrf_token")
if csrf_token != current_auth.session.csrf_token:
logger.warning("Rejected config update due to CSRF validation failure")
context = _build_config_context(
auth_db_session=auth_db_session,
settings=settings,
current_auth=current_auth,
config_saved=False,
config_error="invalid config update request",
password_change_error=None,
ticktick_oauth_notice=None,
ticktick_oauth_error=None,
smtp_test_notice=None,
smtp_test_error=None,
)
return templates.TemplateResponse(
request,
"config.html",
context,
status_code=status.HTTP_400_BAD_REQUEST,
)
try:
save_config_updates(auth_db_session, dict(form), settings)
except ConfigSaveError:
logger.warning("Rejected config update due to invalid submitted values")
refreshed_settings = get_settings()
context = _build_config_context(
auth_db_session=auth_db_session,
settings=refreshed_settings,
current_auth=current_auth,
config_saved=False,
config_error="invalid config submission",
password_change_error=None,
ticktick_oauth_notice=None,
ticktick_oauth_error=None,
smtp_test_notice=None,
smtp_test_error=None,
)
return templates.TemplateResponse(
request,
"config.html",
context,
status_code=status.HTTP_400_BAD_REQUEST,
)
return RedirectResponse(url="/config?saved=1", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/config/smtp/test", response_class=HTMLResponse)
async def smtp_test_submit(
request: Request,
auth_db_session: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
form = await request.form()
csrf_token = form.get("csrf_token")
if csrf_token != current_auth.session.csrf_token:
logger.warning("Rejected SMTP test due to CSRF validation failure")
context = _build_config_context(
auth_db_session=auth_db_session,
settings=settings,
current_auth=current_auth,
config_saved=False,
config_error=None,
password_change_error=None,
ticktick_oauth_notice=None,
ticktick_oauth_error=None,
smtp_test_notice=None,
smtp_test_error="invalid SMTP test request",
)
return templates.TemplateResponse(
request,
"config.html",
context,
status_code=status.HTTP_400_BAD_REQUEST,
)
try:
send_smtp_test_email(settings)
except EmailConfigurationError as exc:
logger.warning("SMTP test email rejected due to configuration: %s", exc)
return RedirectResponse(
url="/config?smtp_test=config-error",
status_code=status.HTTP_303_SEE_OTHER,
)
except EmailDeliveryError as exc:
logger.warning("SMTP test email failed: %s", exc)
return RedirectResponse(
url="/config?smtp_test=failed",
status_code=status.HTTP_303_SEE_OTHER,
)
return RedirectResponse(
url="/config?smtp_test=success",
status_code=status.HTTP_303_SEE_OTHER,
)
+9
View File
@@ -37,6 +37,9 @@ class Settings(BaseSettings):
auth_session_cookie_name: str = "home_automation_session" auth_session_cookie_name: str = "home_automation_session"
auth_session_ttl_hours: int = 12 auth_session_ttl_hours: int = 12
auth_cookie_secure_override: bool | None = True auth_cookie_secure_override: bool | None = True
auth_login_throttle_enabled: bool = True
auth_trust_forwarded_for: bool = False
auth_totp_issuer: str = "" # defaults to app_name when empty
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
@@ -86,6 +89,12 @@ class Settings(BaseSettings):
return self.auth_cookie_secure_override return self.auth_cookie_secure_override
return not self.is_development return not self.is_development
@computed_field
@property
def effective_totp_issuer(self) -> str:
"""The issuer label shown in Authenticator apps. Falls back to app_name."""
return self.auth_totp_issuer.strip() or self.app_name
@lru_cache @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:
+55 -5
View File
@@ -1,15 +1,20 @@
import logging
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import models # noqa: F401 from app import models # noqa: F401
from app.api.routes.auth import router as auth_router from app.api.routes.api.config import router as api_config_router
from app.api.routes import pages, status from app.api.routes.api.data import router as api_data_router
from app.api.routes.api.session import router as api_session_router
from app.api.routes import status
from app.db import get_session_local from app.db import get_session_local
from app.api.routes.homeassistant import router as homeassistant_router from app.api.routes.homeassistant import router as homeassistant_router
from app.api.routes.location import router as location_router from app.api.routes.location import router as location_router
@@ -22,6 +27,17 @@ from app.services.config_page import seed_missing_config_from_bootstrap, sync_ap
from app.services.public_ip import check_public_ipv4_and_notify from app.services.public_ip import check_public_ipv4_and_notify
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
logger = logging.getLogger(__name__)
_REPO_ROOT = Path(__file__).resolve().parents[1]
def _get_spa_dist_dir() -> Path:
env_val = os.environ.get("SPA_DIST_DIR")
if env_val:
return Path(env_val)
return _REPO_ROOT / "frontend" / "dist"
def _run_scheduled_public_ip_check() -> None: def _run_scheduled_public_ip_check() -> None:
session_local = get_session_local() session_local = get_session_local()
@@ -89,13 +105,47 @@ def create_app() -> FastAPI:
app.mount("/static", StaticFiles(directory=static_dir), name="static") app.mount("/static", StaticFiles(directory=static_dir), name="static")
app.include_router(status.router) app.include_router(status.router)
app.include_router(auth_router) app.include_router(api_config_router)
app.include_router(pages.router) app.include_router(api_data_router)
app.include_router(api_session_router)
app.include_router(homeassistant_router) app.include_router(homeassistant_router)
app.include_router(location_router) app.include_router(location_router)
app.include_router(poo_router) app.include_router(poo_router)
app.include_router(public_ip_router) app.include_router(public_ip_router)
app.include_router(ticktick_router) app.include_router(ticktick_router)
# SPA hosting: mount frontend/dist if it exists and has index.html.
# If the SPA dist is absent (e.g. backend-only CI), skip SPA serving entirely
# so that pytest stays green with only the API routes registered.
spa_dist = _get_spa_dist_dir()
spa_index = spa_dist / "index.html"
if spa_dist.is_dir() and spa_index.is_file():
spa_assets = spa_dist / "assets"
if spa_assets.is_dir():
app.mount("/assets", StaticFiles(directory=spa_assets), name="spa-assets")
# Resolve the dist root once so the containment check is fast and consistent.
_spa_root = spa_dist.resolve()
@app.get("/{full_path:path}", include_in_schema=False)
async def spa_fallback(full_path: str, request: Request) -> FileResponse: # noqa: RUF029
# Explicit 404 for unmatched /api/* — never return index.html for API paths.
if full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="not found")
# Resolve candidate to an absolute path and verify it stays within the SPA
# dist root. Without this check, URL-encoded ".." sequences (e.g. "..%2f")
# bypass Starlette's path parameter handling and allow arbitrary file reads.
candidate = (spa_dist / full_path).resolve()
if candidate.is_file() and candidate.is_relative_to(_spa_root):
return FileResponse(candidate)
# For any path outside the dist root, or for SPA client routes that don't
# correspond to a real file, return index.html so the SPA router handles it.
return FileResponse(spa_index)
else:
logger.warning(
"SPA dist not found at %s — SPA hosting disabled (API-only mode).", spa_dist
)
return app return app
+22
View File
@@ -15,8 +15,12 @@ class AuthUser(Base):
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
force_password_change: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) force_password_change: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# TOTP fields (Phase B, M4-T04) — nullable/false by default so existing users are unaffected
totp_secret: Mapped[str | None] = mapped_column(String(64), nullable=True)
totp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
sessions: Mapped[list["AuthSession"]] = relationship(back_populates="user") sessions: Mapped[list["AuthSession"]] = relationship(back_populates="user")
recovery_codes: Mapped[list["RecoveryCode"]] = relationship(back_populates="user")
class AuthSession(Base): class AuthSession(Base):
@@ -31,3 +35,21 @@ class AuthSession(Base):
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
user: Mapped[AuthUser] = relationship(back_populates="sessions") user: Mapped[AuthUser] = relationship(back_populates="sessions")
class RecoveryCode(Base):
"""One-time TOTP recovery codes for AuthUser.
``code_hash`` stores the Argon2 hash of the plaintext code (plaintext is
returned only at setup time and never stored). ``used_at`` is NULL while
the code is still valid; set to the consumption timestamp when consumed.
"""
__tablename__ = "auth_recovery_code"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("auth_users.id"), nullable=False, index=True)
code_hash: Mapped[str] = mapped_column(String(255), nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
user: Mapped[AuthUser] = relationship(back_populates="recovery_codes")
+31
View File
@@ -0,0 +1,31 @@
from datetime import datetime
from sqlalchemy import DateTime, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class LoginThrottle(Base):
"""Tracks per-key login failure state for exponential back-off throttling.
``scope`` is either ``'ip'`` or ``'user'``; ``key`` is the IP address or
username string respectively. The pair ``(scope, key)`` is unique — one
row per tracked entity. A row is deleted on successful login (clear).
"""
__tablename__ = "auth_login_throttle"
__table_args__ = (
UniqueConstraint("scope", "key", name="uq_auth_login_throttle_scope_key"),
Index("ix_auth_login_throttle_scope_key", "scope", "key"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
key: Mapped[str] = mapped_column(String(255), nullable=False)
scope: Mapped[str] = mapped_column(String(16), nullable=False) # 'ip' | 'user'
failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
first_failed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
last_failed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
next_allowed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel
class ConfigField(BaseModel):
env_name: str
label: str
value: str
secret: bool
input_type: str
configured: bool
class ConfigSection(BaseModel):
name: str
fields: list[ConfigField]
class ConfigResponse(BaseModel):
sections: list[ConfigSection]
class ConfigUpdateRequest(BaseModel):
"""Flat mapping of env_name → value, mirroring the existing form semantics."""
updates: dict[str, str]
class ConfigUpdateResponse(BaseModel):
sections: list[ConfigSection]
class SmtpTestResponse(BaseModel):
"""Response from POST /api/config/smtp/test."""
result: Literal["success", "config-error", "failed"]
message: str
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
# ---------------------------------------------------------------------------
# Location
# ---------------------------------------------------------------------------
class LocationRecord(BaseModel):
person: str
datetime: str
latitude: float
longitude: float
altitude: float | None
class LocationsResponse(BaseModel):
items: list[LocationRecord]
limit: int
offset: int
class LocationUpdateRequest(BaseModel):
"""PATCH body for a location record — all fields optional; PK fields excluded."""
latitude: float | None = None
longitude: float | None = None
altitude: float | None = None
# ---------------------------------------------------------------------------
# Poo
# ---------------------------------------------------------------------------
class PooRecord(BaseModel):
timestamp: str
status: str
latitude: float
longitude: float
class PooResponse(BaseModel):
items: list[PooRecord]
limit: int
offset: int
class PooUpdateRequest(BaseModel):
"""PATCH body for a poo record — all fields optional; PK field excluded."""
status: str | None = None
latitude: float | None = None
longitude: float | None = None
# ---------------------------------------------------------------------------
# Public IP
# ---------------------------------------------------------------------------
class PublicIPStateSchema(BaseModel):
id: int
current_ipv4: str
previous_ipv4: str | None
first_seen_at: datetime
last_checked_at: datetime
last_changed_at: datetime | None
last_check_status: str
last_check_error: str | None
last_provider: str | None
model_config = {"from_attributes": True}
class PublicIPHistorySchema(BaseModel):
id: int
ipv4: str
observed_at: datetime
change_type: str
provider: str | None
model_config = {"from_attributes": True}
class PublicIPResponse(BaseModel):
state: PublicIPStateSchema | None
history: list[PublicIPHistorySchema]
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from pydantic import BaseModel
class SessionUser(BaseModel):
username: str
force_password_change: bool
class SessionResponse(BaseModel):
user: SessionUser
csrf_token: str
class LoginRequest(BaseModel):
username: str
password: str
totp_code: str | None = None
class PasswordChangeRequest(BaseModel):
current_password: str
new_password: str
confirm_password: str
+59
View File
@@ -0,0 +1,59 @@
"""Pydantic schemas for TOTP setup / enable / disable / status endpoints (M4-T05)."""
from __future__ import annotations
from pydantic import BaseModel
# ---------------------------------------------------------------------------
# Setup (POST /api/auth/totp/setup) — response
# ---------------------------------------------------------------------------
class TotpSetupResponse(BaseModel):
"""Returned once after a setup call.
``secret`` and ``recovery_codes`` are **one-time plaintext values**.
They are never returned again by any subsequent API call.
The frontend must display and instruct the user to save them before confirming.
"""
secret: str
otpauth_uri: str
recovery_codes: list[str]
# ---------------------------------------------------------------------------
# Enable (POST /api/auth/totp/enable) — request
# ---------------------------------------------------------------------------
class TotpEnableRequest(BaseModel):
"""The user confirms setup by providing the 6-digit TOTP code."""
code: str
# ---------------------------------------------------------------------------
# Disable (POST /api/auth/totp/disable) — request
# ---------------------------------------------------------------------------
class TotpDisableRequest(BaseModel):
"""Disable TOTP by proving identity.
Exactly one of ``password`` or ``code`` must be provided.
"""
password: str | None = None
code: str | None = None
# ---------------------------------------------------------------------------
# Status (GET /api/auth/totp) — response
# ---------------------------------------------------------------------------
class TotpStatusResponse(BaseModel):
"""Minimal status response — never exposes secret or recovery codes."""
enabled: bool
+8
View File
@@ -49,6 +49,12 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = (
"auth_cookie_secure_override", "auth_cookie_secure_override",
"Cookie Secure Override", "Cookie Secure Override",
), ),
ConfigField(
"Authentication",
"AUTH_LOGIN_THROTTLE_ENABLED",
"auth_login_throttle_enabled",
"Login Throttle Enabled",
),
ConfigField("Poo", "POO_WEBHOOK_ID", "poo_webhook_id", "Poo Webhook ID", secret=True), ConfigField("Poo", "POO_WEBHOOK_ID", "poo_webhook_id", "Poo Webhook ID", secret=True),
ConfigField( ConfigField(
"Poo", "Poo",
@@ -284,4 +290,6 @@ def _settings_payload(settings: Settings) -> dict[str, Any]:
"auth_session_cookie_name": settings.auth_session_cookie_name, "auth_session_cookie_name": settings.auth_session_cookie_name,
"auth_session_ttl_hours": settings.auth_session_ttl_hours, "auth_session_ttl_hours": settings.auth_session_ttl_hours,
"auth_cookie_secure_override": settings.auth_cookie_secure_override, "auth_cookie_secure_override": settings.auth_cookie_secure_override,
"auth_login_throttle_enabled": settings.auth_login_throttle_enabled,
"auth_trust_forwarded_for": settings.auth_trust_forwarded_for,
} }
+56 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import insert from sqlalchemy import delete, insert, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.location import Location from app.models.location import Location
@@ -40,3 +40,58 @@ def record_location(session: Session, payload: LocationRecordRequest) -> None:
) )
session.execute(stmt) session.execute(stmt)
session.commit() session.commit()
def update_location(
session: Session,
person: str,
datetime_pk: str,
*,
latitude: float | None,
longitude: float | None,
altitude: float | None,
) -> Location | None:
"""Update non-PK fields of a single location row.
Returns the updated ORM object, or ``None`` if the PK does not exist.
The caller must not pass PK fields — they are immutable.
Only fields with a non-``None`` value are written; ``altitude`` being
``None`` in the request means "leave unchanged", not "clear to NULL".
"""
row = session.execute(
select(Location).where(
Location.person == person,
Location.datetime == datetime_pk,
)
).scalar_one_or_none()
if row is None:
return None
if latitude is not None:
row.latitude = latitude
if longitude is not None:
row.longitude = longitude
if altitude is not None:
row.altitude = altitude
session.commit()
session.refresh(row)
return row
def delete_location(session: Session, person: str, datetime_pk: str) -> bool:
"""Delete the single location row identified by its full composite PK.
Returns ``True`` if exactly one row was deleted, ``False`` if the PK did
not exist (caller should raise 404). The DELETE is scoped to the exact PK
— no batch/truncate path exists.
"""
result = session.execute(
delete(Location).where(
Location.person == person,
Location.datetime == datetime_pk,
)
)
session.commit()
return result.rowcount == 1
+179
View File
@@ -0,0 +1,179 @@
"""Exponential back-off throttle service for the login endpoint.
Design
------
Failures are tracked per (scope, key) in the ``auth_login_throttle`` table:
* scope ``'ip'`` → key is the client IP address
* scope ``'user'`` → key is the username
On each login attempt the endpoint checks **both** keys and takes the **maximum**
remaining wait (in seconds). A wait > 0 means the caller is still inside a
back-off window and should receive 429.
Exponential formula
-------------------
The first ``N_FREE`` failures are free (no delay). After that::
wait = min(BACKOFF_CAP_S, BACKOFF_BASE_S * 2 ** (failures - N_FREE))
Constants (module-level so they can be tuned without touching the algorithm):
* ``N_FREE`` = 3 — failures before any delay starts
* ``BACKOFF_BASE_S`` = 1 — base wait in seconds (1st delayed attempt → 2 s)
* ``BACKOFF_CAP_S`` = 900 — hard cap (15 min)
Example delay schedule:
failures 1, 2, 3 → 0 s (free)
failures 4 → 2 s
failures 5 → 4 s
failures 6 → 8 s
failures 7 → 16 s
failures 13+ → 900 s (capped)
Timezone notes
--------------
The model uses ``DateTime(timezone=True)``. SQLite stores timestamps without
timezone info, so when rows are read back the column value may be timezone-naive.
We normalise every stored value to UTC-aware via ``_as_utc`` before comparing
against ``datetime.now(UTC)``.
"""
from __future__ import annotations
import math
from datetime import UTC, datetime, timedelta
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.models.auth_throttle import LoginThrottle
# ---------------------------------------------------------------------------
# Tunable constants
# ---------------------------------------------------------------------------
N_FREE: int = 3 # failures before any backoff starts
BACKOFF_BASE_S: int = 1 # seconds — wait after the first delayed failure
BACKOFF_CAP_S: int = 900 # seconds — maximum wait (15 min)
# Internal scope labels (keep in sync with model constraint)
_SCOPE_IP = "ip"
_SCOPE_USER = "user"
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def check_and_get_wait(session: Session, *, ip: str, username: str) -> int:
"""Return the number of seconds the caller must still wait, or 0 if allowed.
Checks both the IP row and the username row; returns the *larger* remaining
wait so that both keys must have their back-off window expire before the
caller is permitted to try again.
"""
now = datetime.now(UTC)
wait_ip = _remaining_wait(session, scope=_SCOPE_IP, key=ip, now=now)
wait_user = _remaining_wait(session, scope=_SCOPE_USER, key=username, now=now)
return max(wait_ip, wait_user)
def register_failure(session: Session, *, ip: str, username: str) -> None:
"""Record one login failure for both the IP and the username keys.
Updates (or creates) the throttle row for each key, increments the failure
counter, and recomputes ``next_allowed_at`` using the exponential formula.
"""
now = datetime.now(UTC)
_upsert_failure(session, scope=_SCOPE_IP, key=ip, now=now)
_upsert_failure(session, scope=_SCOPE_USER, key=username, now=now)
session.commit()
def clear(session: Session, *, ip: str, username: str) -> None:
"""Delete the throttle rows for the given IP and username (successful login).
It is safe to call this even if no rows exist for the given keys.
"""
session.execute(
delete(LoginThrottle).where(
(LoginThrottle.scope == _SCOPE_IP) & (LoginThrottle.key == ip)
| (LoginThrottle.scope == _SCOPE_USER) & (LoginThrottle.key == username)
)
)
session.commit()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _remaining_wait(session: Session, *, scope: str, key: str, now: datetime) -> int:
"""Return the number of seconds that ``key`` in ``scope`` must still wait.
Returns 0 if the key has no throttle row or if the back-off window has
already expired.
"""
row = session.scalar(
select(LoginThrottle)
.where(LoginThrottle.scope == scope, LoginThrottle.key == key)
.limit(1)
)
if row is None or row.next_allowed_at is None:
return 0
next_allowed = _as_utc(row.next_allowed_at)
if next_allowed <= now:
return 0
remaining = (next_allowed - now).total_seconds()
# Round up so callers are never told "0 seconds" while still blocked.
return math.ceil(remaining)
def _compute_next_allowed_at(failures: int, now: datetime) -> datetime | None:
"""Compute the earliest time the key should be allowed to try again.
Returns ``None`` for the first ``N_FREE`` failures (no delay).
"""
if failures <= N_FREE:
return None
wait_s = min(BACKOFF_CAP_S, BACKOFF_BASE_S * (2 ** (failures - N_FREE)))
return now + timedelta(seconds=wait_s)
def _upsert_failure(session: Session, *, scope: str, key: str, now: datetime) -> None:
"""Create or update the throttle row for (scope, key) with one more failure."""
row = session.scalar(
select(LoginThrottle)
.where(LoginThrottle.scope == scope, LoginThrottle.key == key)
.limit(1)
)
if row is None:
new_failures = 1
row = LoginThrottle(
scope=scope,
key=key,
failures=new_failures,
first_failed_at=now,
last_failed_at=now,
next_allowed_at=_compute_next_allowed_at(new_failures, now),
)
session.add(row)
else:
row.failures += 1
row.last_failed_at = now
row.next_allowed_at = _compute_next_allowed_at(row.failures, now)
def _as_utc(value: datetime | None) -> datetime | None:
"""Normalise a possibly-naive datetime to UTC-aware."""
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
+48 -1
View File
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
import logging import logging
from sqlalchemy import desc, insert, select from sqlalchemy import delete, desc, insert, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import Settings from app.config import Settings
@@ -74,6 +74,53 @@ def record_poo(
logger.warning("Failed to trigger poo webhook on Home Assistant: %s", exc) logger.warning("Failed to trigger poo webhook on Home Assistant: %s", exc)
def update_poo_record(
session: Session,
timestamp_pk: str,
*,
status: str | None,
latitude: float | None,
longitude: float | None,
) -> PooRecord | None:
"""Update non-PK fields of a single poo record row.
Returns the updated ORM object, or ``None`` if the PK does not exist.
The ``timestamp`` PK is immutable and must not be passed as an update field.
Only fields with a non-``None`` value are written.
"""
row = session.execute(
select(PooRecord).where(PooRecord.timestamp == timestamp_pk)
).scalar_one_or_none()
if row is None:
return None
if status is not None:
row.status = status
if latitude is not None:
row.latitude = latitude
if longitude is not None:
row.longitude = longitude
session.commit()
session.refresh(row)
return row
def delete_poo_record(session: Session, timestamp_pk: str) -> bool:
"""Delete the single poo record row identified by its PK.
Returns ``True`` if exactly one row was deleted, ``False`` if the PK did
not exist (caller should raise 404). The DELETE is scoped to the exact PK
— no batch/truncate path exists.
"""
result = session.execute(
delete(PooRecord).where(PooRecord.timestamp == timestamp_pk)
)
session.commit()
return result.rowcount == 1
def get_latest_poo_record(session: Session) -> LatestPooRecord | None: def get_latest_poo_record(session: Session) -> LatestPooRecord | None:
stmt = select(PooRecord).order_by(desc(PooRecord.timestamp)).limit(1) stmt = select(PooRecord).order_by(desc(PooRecord.timestamp)).limit(1)
record = session.execute(stmt).scalar_one_or_none() record = session.execute(stmt).scalar_one_or_none()
+234
View File
@@ -0,0 +1,234 @@
"""TOTP service: secret management, recovery-code lifecycle, setup/enable/disable (M4-T05).
State-machine summary
---------------------
DISABLED (default)
totp_secret=None, totp_enabled=False, no recovery codes
PENDING (after setup, before enable)
totp_secret=<base32>, totp_enabled=False, recovery codes stored as hashes
- Re-calling setup replaces the pending secret and regenerates recovery codes.
ENABLED (after enable)
totp_secret=<base32>, totp_enabled=True, recovery codes stored as hashes
After disable:
totp_secret=None, totp_enabled=False, recovery codes deleted → back to DISABLED
Recovery-code timing
--------------------
1. setup → generate 10 plaintext codes, persist their Argon2 hashes immediately,
return plaintext to caller (ONLY time).
2. enable → verify 6-digit TOTP code; if ok, flip totp_enabled=True.
Recovery codes are already in the DB from step 1.
3. disable → clear secret, clear enabled flag, delete all recovery codes.
Security note: ``totp_secret`` is stored as plaintext (same posture as other
secrets in this project: protected by file-system permissions on the SQLite
database). Recovery codes are stored as Argon2 hashes only.
"""
from __future__ import annotations
import logging
import secrets
import string
import pyotp
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.models.auth import AuthUser, RecoveryCode
from app.services.auth import hash_password, verify_password
logger = logging.getLogger(__name__)
# Number of recovery codes to generate per setup
_RECOVERY_CODE_COUNT = 10
# Alphabet for each 4-character segment: lowercase letters + digits, no ambiguous chars
_CODE_ALPHABET = string.ascii_lowercase + string.digits
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _generate_recovery_code() -> str:
"""Return a single recovery code in ``xxxx-xxxx`` format."""
segment = lambda: "".join(secrets.choice(_CODE_ALPHABET) for _ in range(4)) # noqa: E731
return f"{segment()}-{segment()}"
def _verify_totp_code(secret: str, code: str) -> bool:
"""Verify a 6-digit TOTP code against the given base-32 secret (±1 window)."""
return pyotp.TOTP(secret).verify(code, valid_window=1)
def _delete_recovery_codes(db: Session, *, user_id: int) -> None:
"""Delete ALL recovery codes for a user (called on setup re-run and disable)."""
db.execute(delete(RecoveryCode).where(RecoveryCode.user_id == user_id))
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def setup(
db: Session,
*,
user: AuthUser,
issuer: str,
) -> tuple[str, str, list[str]]:
"""Generate a new pending TOTP secret and recovery codes.
The secret is stored in ``user.totp_secret`` (``totp_enabled`` stays
``False``). Any pre-existing pending secret and recovery codes are
replaced atomically.
Returns
-------
(secret, otpauth_uri, plaintext_recovery_codes)
``plaintext_recovery_codes`` are returned **once** here and MUST NOT be
stored or returned elsewhere.
"""
# Generate new TOTP secret
new_secret = pyotp.random_base32()
otpauth_uri = pyotp.TOTP(new_secret).provisioning_uri(
name=user.username,
issuer_name=issuer,
)
# Generate plaintext recovery codes
plaintext_codes = [_generate_recovery_code() for _ in range(_RECOVERY_CODE_COUNT)]
# --- Persist atomically ---
# 1. Delete any old pending recovery codes (idempotent on re-setup)
assert user.id is not None # mypy guard; always set after DB insertion
_delete_recovery_codes(db, user_id=user.id)
# 2. Update the secret (pending — totp_enabled stays False)
user.totp_secret = new_secret
# 3. Persist new recovery codes as Argon2 hashes
for plaintext in plaintext_codes:
db.add(RecoveryCode(user_id=user.id, code_hash=hash_password(plaintext)))
db.commit()
db.refresh(user)
logger.info("TOTP setup (pending) for user '%s'; %d recovery codes generated.", user.username, _RECOVERY_CODE_COUNT)
return new_secret, otpauth_uri, plaintext_codes
def enable(db: Session, *, user: AuthUser, code: str) -> bool:
"""Enable TOTP after the user proves they can generate the correct code.
Parameters
----------
code:
The current 6-digit TOTP code from the user's authenticator app.
Returns
-------
True on success; False if the code is invalid or no secret is pending.
"""
if not user.totp_secret:
logger.info("TOTP enable rejected for '%s': no pending secret.", user.username)
return False
if not _verify_totp_code(user.totp_secret, code):
logger.info("TOTP enable rejected for '%s': wrong code.", user.username)
return False
user.totp_enabled = True
db.commit()
db.refresh(user)
logger.info("TOTP enabled for user '%s'.", user.username)
return True
def disable(
db: Session,
*,
user: AuthUser,
password: str | None = None,
code: str | None = None,
) -> bool:
"""Disable TOTP. Caller must supply exactly one of ``password`` or ``code``.
On success: ``totp_enabled=False``, ``totp_secret=None``, all recovery
codes deleted.
Returns
-------
True on success; False if neither credential is valid.
"""
if not password and not code:
logger.info("TOTP disable rejected for '%s': no credential provided.", user.username)
return False
if password:
if not verify_password(password, user.password_hash):
logger.info("TOTP disable rejected for '%s': wrong password.", user.username)
return False
elif code:
if not user.totp_secret or not _verify_totp_code(user.totp_secret, code):
logger.info("TOTP disable rejected for '%s': wrong TOTP code.", user.username)
return False
# Clear TOTP state
assert user.id is not None
_delete_recovery_codes(db, user_id=user.id)
user.totp_enabled = False
user.totp_secret = None
db.commit()
db.refresh(user)
logger.info("TOTP disabled for user '%s'.", user.username)
return True
def verify_totp_code(user: AuthUser, code: str) -> bool:
"""Verify a 6-digit TOTP code for a user.
Returns True if the code is valid (±1 time window), False otherwise.
The user must have a ``totp_secret`` set; returns False if not.
This is the public entry-point for T06 login two-factor verification.
"""
if not user.totp_secret:
return False
return _verify_totp_code(user.totp_secret, code)
def verify_recovery_code(db: Session, *, user: AuthUser, code: str) -> bool:
"""Verify and consume a one-time recovery code.
Finds the first unused recovery code whose hash matches ``code``, marks it
as consumed (sets ``used_at``), and returns ``True``. Returns ``False`` if
no matching unused code exists.
This function is provided for T06 (login two-factor) but lives here so the
TOTP service owns all recovery-code logic.
"""
from datetime import UTC, datetime
unused = db.execute(
select(RecoveryCode).where(
RecoveryCode.user_id == user.id,
RecoveryCode.used_at.is_(None),
)
).scalars().all()
for rc in unused:
if verify_password(code, rc.code_hash):
rc.used_at = datetime.now(UTC)
db.commit()
logger.info(
"Recovery code consumed for user '%s' (id=%d).", user.username, rc.id
)
return True
return False
-16
View File
@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<main class="shell">
{% block content %}{% endblock %}
</main>
</body>
</html>
-139
View File
@@ -1,139 +0,0 @@
{% extends "base.html" %}
{% block title %}Config · {{ app_name }}{% endblock %}
{% block content %}
<section class="panel">
<p class="eyebrow">Configuration</p>
<h1>Config</h1>
{% if force_password_change %}
<div class="alert">
首次登录后需要先修改密码。完成后再继续长期使用当前配置页面。
</div>
{% endif %}
{% if password_change_error %}
<div class="alert">{{ password_change_error }}</div>
{% endif %}
{% if config_error %}
<div class="alert">{{ config_error }}</div>
{% endif %}
{% if config_saved %}
<div class="notice">config saved to the app database. Some changes may require an app restart.</div>
{% endif %}
{% if ticktick_oauth_error %}
<div class="alert">{{ ticktick_oauth_error }}</div>
{% endif %}
{% if ticktick_oauth_notice %}
<div class="notice">{{ ticktick_oauth_notice }}</div>
{% endif %}
{% if smtp_test_error %}
<div class="alert">{{ smtp_test_error }}</div>
{% endif %}
{% if smtp_test_notice %}
<div class="notice">{{ smtp_test_notice }}</div>
{% endif %}
<div class="meta single-column">
<div>
<dt>当前用户</dt>
<dd>admin</dd>
</div>
</div>
<section class="config-block">
<h2>Change Password</h2>
<form class="auth-form" method="post" action="/config/change-password">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>
<span>Current Password</span>
<input type="password" name="current_password" autocomplete="current-password" required>
</label>
<label>
<span>New Password</span>
<input type="password" name="new_password" autocomplete="new-password" required>
</label>
<label>
<span>Confirm New Password</span>
<input type="password" name="confirm_password" autocomplete="new-password" required>
</label>
<button type="submit">修改密码</button>
</form>
</section>
<section class="config-block">
<h2>Config</h2>
<form class="config-form" method="post" action="/config">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% for section in config_sections %}
<fieldset class="config-section">
<legend>{{ section.name }}</legend>
{% for field in section.fields %}
<label>
<span>{{ field.label }}</span>
{% if field.secret %}
<input type="{{ field.input_type }}" name="{{ field.env_name }}" value="" placeholder="leave blank to keep current value">
<small>{% if field.configured %}configured{% else %}not configured{% endif %}</small>
{% else %}
<input type="{{ field.input_type }}" name="{{ field.env_name }}" value="{{ field.value }}">
{% endif %}
</label>
{% endfor %}
{% if section.name == "TickTick" %}
<div class="integration-action-row">
<div>
<p class="integration-action-title">TickTick OAuth</p>
<p class="integration-action-copy">Redirect URI: {{ ticktick_redirect_uri or "configure APP_HOSTNAME to generate the callback URI" }}</p>
{% if ticktick_oauth_ready %}
<p class="integration-action-copy">Use the saved TickTick client settings to start the authorization flow.</p>
{% else %}
<p class="integration-action-copy">Fill in App Hostname, TickTick Client ID, and TickTick Client Secret before starting OAuth.</p>
{% endif %}
</div>
{% if ticktick_oauth_ready %}
<a class="button-link" href="/ticktick/auth/start">Authorize TickTick</a>
{% else %}
<span class="button-link disabled" aria-disabled="true">Authorize TickTick</span>
{% endif %}
</div>
{% endif %}
{% if section.name == "SMTP" %}
<div class="integration-action-row">
<div>
<p class="integration-action-title">SMTP Test Email</p>
<p class="integration-action-copy">Save the SMTP settings first, then send a simple plaintext test email to the configured recipient.</p>
</div>
{% if smtp_test_ready %}
<button type="submit" formaction="/config/smtp/test" formmethod="post">Send SMTP Test</button>
{% else %}
<span class="button-link disabled" aria-disabled="true">Send SMTP Test</span>
{% endif %}
</div>
{% endif %}
</fieldset>
{% endfor %}
<button type="submit">Save Config</button>
</form>
</section>
<form class="logout-form" method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">登出</button>
</form>
</section>
{% endblock %}
-36
View File
@@ -1,36 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ app_name }}{% endblock %}
{% block content %}
<section class="panel">
<p class="eyebrow">Python Rewrite Skeleton</p>
<h1>{{ app_name }}</h1>
<p class="lead">
这是当前 Go 后端的 Python 重构基础骨架。此阶段仅提供应用入口、配置、数据库、
测试、模板和容器化基础,不包含业务逻辑迁移。
</p>
<dl class="meta">
<div>
<dt>运行环境</dt>
<dd>{{ app_env }}</dd>
</div>
<div>
<dt>健康检查</dt>
<dd><a href="/status">/status</a></dd>
</div>
<div>
<dt>OpenAPI</dt>
<dd><a href="/docs">/docs</a></dd>
</div>
<div>
<dt>登录</dt>
<dd><a href="/login">/login</a></dd>
</div>
<div>
<dt>Notion</dt>
<dd>{{ notion_status }}</dd>
</div>
</dl>
</section>
{% endblock %}
-33
View File
@@ -1,33 +0,0 @@
{% extends "base.html" %}
{% block title %}登录 · {{ app_name }}{% endblock %}
{% block content %}
<section class="panel auth-panel">
<p class="eyebrow">Authentication</p>
<h1>登录</h1>
<p class="lead">
登录成功后会进入受保护的 config 页面。
</p>
{% if error_message %}
<div class="alert">{{ error_message }}</div>
{% endif %}
<form class="auth-form" method="post" action="/login">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>
<span>Username</span>
<input type="text" name="username" autocomplete="username" required>
</label>
<label>
<span>Password</span>
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button type="submit">登录</button>
</form>
</section>
{% endblock %}
+3 -5
View File
@@ -53,14 +53,10 @@ idna==3.11
# httpx # httpx
iniconfig==2.3.0 iniconfig==2.3.0
# via pytest # via pytest
jinja2==3.1.6
# via -r requirements.in
mako==1.3.11 mako==1.3.11
# via alembic # via alembic
markupsafe==3.0.3 markupsafe==3.0.3
# via # via mako
# jinja2
# mako
packaging==26.1 packaging==26.1
# via # via
# build # build
@@ -82,6 +78,8 @@ pydantic-settings==2.13.1
# via -r requirements.in # via -r requirements.in
pygments==2.20.0 pygments==2.20.0
# via pytest # via pytest
pyotp==2.10.0
# via -r requirements.in
pyproject-hooks==1.2.0 pyproject-hooks==1.2.0
# via # via
# build # build
+28
View File
@@ -0,0 +1,28 @@
# Local dev override — use explicitly:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
# Isolated from the production stack so both can run on this host at once:
# - distinct compose project name (separate network/grouping)
# - distinct container names (-dev suffix; Docker rejects duplicate names)
# - distinct image tag (local build doesn't clobber the prod :latest tag)
name: home-automation-dev
services:
migration:
build: .
image: home-automation:dev
container_name: home-automation-migration-dev
environment:
# In-container path for the mounted ./data volume (./data -> /app/data).
# Overrides the host-absolute APP_DATABASE_URL in .env for local compose runs.
APP_DATABASE_URL: "sqlite:////app/data/app.db"
app:
build: .
image: home-automation:dev
container_name: home-automation-app-dev
# Publish on 8002 for dev. `!override` REPLACES the base ports list instead of
# appending to it, so the dev stack does NOT also bind the production 8881.
ports: !override
- "127.0.0.1:8002:8000"
environment:
APP_DATABASE_URL: "sqlite:////app/data/app.db"
-6
View File
@@ -1,6 +0,0 @@
services:
migration:
build: .
app:
build: .
+34 -9
View File
@@ -29,10 +29,8 @@
- 通用依赖注入 - 通用依赖注入
- `api/` - `api/`
- HTTP routes - HTTP routes
- 当前已迁入 `/login``/logout``/admin` - `api/routes/api/`JSON API`/api/*` 前缀),供 React SPA 调用:会话/鉴权、配置读写、数据查询、记录 CRUD
- 当前已迁入 `GET /public-ip/check` - 裸 ingestion 端点:`GET /public-ip/check``POST /homeassistant/publish``POST /poo/record``GET /poo/latest`、TickTick OAuth 等
- 当前已迁入 `POST /homeassistant/publish` 第一版入口
- 当前已迁入 `POST /poo/record``GET /poo/latest`
- `models/` - `models/`
- SQLAlchemy models - SQLAlchemy models
- 所有模型(auth / config / public_ip / location / poo)共用同一个 `Base`,均落在单一 `app.db` - 所有模型(auth / config / public_ip / location / poo)共用同一个 `Base`,均落在单一 `app.db`
@@ -46,8 +44,6 @@
- `integrations/` - `integrations/`
- 外部系统适配层 - 外部系统适配层
- 当前已迁入 Home Assistant outbound adapter - 当前已迁入 Home Assistant outbound adapter
- `templates/`
- Jinja2 模板
- `static/` - `static/`
- 极简静态资源 - 极简静态资源
@@ -63,15 +59,44 @@ pytest 测试目录。后续可以在这里自然扩展:
- mock tests - mock tests
- integration tests - integration tests
### `frontend/`
React SPA 前端(M2 引入)。Vite + React + TypeScript + Mantine,由 FastAPI 同源托管。
- `src/`React 源码
- `src/api/`:由 `openapi/openapi.json` 生成的类型化 client`schema.d.ts`+ fetch 封装
- `dist/``npm run build` 产物,由 FastAPI 的 `SPA_DIST_DIR` 挂载并对非 `/api` 路径做 fallback
### `scripts/` ### `scripts/`
辅助脚本目录。当前包含 OpenAPI 导出脚本。 辅助脚本目录。当前包含
- `export_openapi.py`:导出 OpenAPI schema 静态产物
- `run_migrations.py`:运行 Alembic migration
- `app_db_adopt.py`App DB 接管 / 初始化
- `migrate_legacy_data.py`:一次性历史数据搬迁脚本
- `admin_cli.py`Admin CLI 逃生通道(M4),见下方"登录加固"说明
### `openapi/`
OpenAPI schema 静态产物(`openapi.json` / `openapi.yaml`),由 `python scripts/export_openapi.py` 生成,纳入版本控制。前端 codegen 以此为契约源。
## 登录加固(M4
M4 在基础 Argon2 + server-side session 鉴权之上叠加了三层防御:
**防爆破 / 指数退避**`app/services/login_throttle.py` 按 client IP 与 username 双键记失败计数,失败超过 3 次后指数增长等待时间(最长 15 分钟),`POST /api/auth/login` 在退避窗口内直接返回 `429 + Retry-After`,不执行 Argon2 验证。成功登录后清零。退避是延迟而非永久封号;全局开关 `AUTH_LOGIN_THROTTLE_ENABLED`CONFIG_FIELDS,默认开);反代后需设 `AUTH_TRUST_FORWARDED_FOR=true``.env` 部署级,默认 false)。
**CLI 逃生通道**`scripts/admin_cli.py`(入口 `python -m scripts.admin_cli`)直连本地 DB,**无需 HTTP 服务运行、无需任何已存凭据**,支持:重置密码(`reset-password`)、解锁退避(`unlock`)、关停 TOTP`disable-totp`,零凭据最终逃生)、重新发放 TOTP secret`reissue-totp`)、查看用户列表(`list-admin`)。CLI 只动 auth 行,不触碰用户数据表。
**可选 TOTP 二次验证**:admin 可在设置页自选启用 RFC 6238 TOTP。启用后登录为两步(密码 → 6 位动态码或一次性恢复码);不启用维持纯密码。TOTP secret 明文存库(与其他 secret 一致,靠文件权限保护);恢复码以 Argon2 哈希存储,使用后消费(一次性)。后端使用 `pyotp`,二维码由前端 `qrcode.react` 渲染。issuer 标签由 `AUTH_TOTP_ISSUER``.env` 部署级)配置,默认回退 `app_name`
详细说明:[`docs/auth.md`](./auth.md)
## 当前约束 ## 当前约束
- 当前只搭骨架,不迁业务逻辑
- 当前数据库继续使用 SQLite - 当前数据库继续使用 SQLite
- 当前不引入前后端分离 - ~~当前不引入前后端分离~~ **已退役(M2**:现为 React SPA + JSON `/api` 层,由 FastAPI 同源托管
- 当前不设计 Notion 模块 - 当前不设计 Notion 模块
- 当前通知能力仍保持极小范围,不引入独立通知中心或多渠道抽象 - 当前通知能力仍保持极小范围,不引入独立通知中心或多渠道抽象
+191 -82
View File
@@ -1,120 +1,229 @@
# 基础鉴权说明 # 鉴权说明
本文档说明当前 Python 重构项目里已经落地的第一版鉴权基座 本文档说明当前已落地的鉴权基座(基础 session 鉴权 + M4 登录加固)
这一轮只解决:
- 登录页
- 登录 / 登出流程
- server-side session
- 一个最小受保护页面
这一轮明确不解决:
- 完整 config persistence
- 完整 config CRUD
- 多用户权限系统
- OAuth / SSO / RBAC
## 当前 auth 模型 ## 当前 auth 模型
- 认证方式:`username/password` - 认证方式:`username/password`(可选启用 TOTP 二次验证)
- 会话方式:server-side session - 会话方式:server-side session
- 客户端凭据:session cookie - 客户端凭据:session cookie
- 页面形态:Jinja server-side template
## 当前持久化 ## 持久化
当前新增一个共享 App DB 所有 auth 相关数据存放在单一 App DB(`APP_DATABASE_URL`,默认 `sqlite:///./data/app.db`)中
- `APP_DATABASE_URL` - `auth_users`:用户表(含 TOTP 字段 `totp_secret` / `totp_enabled`
- 默认值:`sqlite:///./data/app.db` - `auth_sessions`session token 哈希与过期时间
- `auth_login_throttle`:登录失败退避状态(按 IP / username 双键)
当前 auth 相关数据存放在这个 DB 中: - `auth_recovery_code`TOTP 恢复码哈希(一次性)
- `app_config`runtime 配置持久化
- `auth_users`
- `auth_sessions`
- `app_config`
当前没有把 auth 数据和 `location` / `poo` DB 混放。
当前这部分现在也走 Alembic 管理:
- Alembic 环境:`alembic_app.ini` + `alembic_app/`
- 初始化脚本:`python scripts/app_db_adopt.py`
当前没有 legacy app DB,所以这一版脚本只负责初始化新库,不负责 legacy adoption。
`app_config` 现在承接运行时配置持久化。
其中:
- `.env` 负责 bootstrap / fallback
- `app_config` 表负责运行时配置覆盖
- 登录密码仍然属于认证数据,使用 Argon2 哈希,不存进 `app_config`
## 首次启动与 bootstrap ## 首次启动与 bootstrap
如果 auth DB 中还没有任何用户,应用启动时会要求 如果 auth DB 中还没有任何用户,应用启动时会使用
- `AUTH_BOOTSTRAP_USERNAME` - `AUTH_BOOTSTRAP_USERNAME`
- `AUTH_BOOTSTRAP_PASSWORD` - `AUTH_BOOTSTRAP_PASSWORD`
创建首个 admin 用户。 创建首个 admin 用户。当前默认 bootstrap 值为:
当前默认 bootstrap 值就是:
- username: `admin` - username: `admin`
- password: `admin` - password: `admin`
首次登录后,系统会强制要求修改密码。 首次登录后,系统会强制要求修改密码。
如果你希望在首次启动前就覆盖默认值,可以直接设置环境变量: ## 基础安全设计
- `AUTH_BOOTSTRAP_USERNAME` 当前这版已经落实的安全点:
- `AUTH_BOOTSTRAP_PASSWORD`
建议流程是:
1. 配好 `.env`
2. 运行 `python scripts/app_db_adopt.py`
3. 启动应用
4.`admin / admin` 首次登录
5. 立即修改密码
## 安全设计
当前这版已经落实的基础安全点:
- 密码不明文存储,使用 Argon2 哈希 - 密码不明文存储,使用 Argon2 哈希
- session cookie 为 `HttpOnly` - session cookie 为 `HttpOnly`
- cookie 使用 `SameSite=Lax` - cookie 使用 `SameSite=Lax`
- `Secure` cookie 在非 `development` 环境默认开启 - `Secure` cookie 在非 `development` 环境默认开启
- 登录表单与登出表单都有基础 CSRF 校验 - 写请求(POST/PUT/PATCH/DELETE)需携带 `X-CSRF-Token` headerSameSite=Lax + 自定义 header 纵深防御,无需 per-session token 值比对)
- session token 为随机生成,服务端只持久化 token hash - session token 为随机生成,服务端只持久化 token hash
- session 有过期时间与显式失效机制 - session 有过期时间(默认 12 小时)与显式失效机制
## 当前受保护范围 ---
当前这轮只保护了页面入口: ## M4 登录加固
- `GET /config` M4 在基础鉴权之上叠加了三层防御:防爆破/指数退避、CLI 逃生通道、可选 TOTP 二次验证。
- `POST /config`
- `POST /config/change-password`
- `POST /logout`
相关流程: ### 1. 防爆破 / 指数退避
- `GET /login` #### 机制
- `POST /login`
登录访问 `/config` 时会被重定向到 `/login` 登录失败按指数增长延迟,目标是拖垮暴力枚举,同时不因此永久锁定账号
## 下一步不在本轮内 **退避是延迟(429 + Retry-After),不是永久封号**——单 admin 场景下永久锁会被攻击者反向用来故意打锁,所以退避只增加等待时间,CLI 是最终逃生口。
后续可以在这个基座上继续做: #### 双键计算
- 配置页面接入 每次登录请求同时按 **client IP****username** 各记一套失败计数,本次需等待时间 = 两者退避的**较大值**。
- config persistence
- 更细的受保护路由范围 - 按 IP:堵单点暴力(攻击 IP 越敲越慢,合法用户换 IP 不受影响)
- 用户初始化 / 密码轮换的更正式 runbook - 按 username:全局兜底(即便攻击者分布式换 IP 也有一层保护)
#### 指数公式
```
N_FREE = 3 — 前 3 次失败不延迟(免费次数)
BASE = 1 秒
CAP = 900 秒(15 分钟)
wait = min(CAP, BASE × 2^(failures - N_FREE)) failures > N_FREE 时生效)
```
延迟示意:
| 累计失败次数 | 等待时间 |
| --- | --- |
| 13 | 0 秒(免费) |
| 4 | 2 秒 |
| 5 | 4 秒 |
| 6 | 8 秒 |
| 7 | 16 秒 |
| … | … |
| 13+ | 900 秒(封顶) |
成功登录后,该 IP 和 username 的退避状态**立即清零**。
#### 登录端点行为(`POST /api/auth/login`
1. 先查退避(在窗口内直接 `429`**不验密码**,节省 Argon2 计算并防止枚举)
2. 验密码失败 → 记一次失败(IP + username 各记),返回 `401`
3. 密码正确但 TOTP 启用且缺少 `totp_code` → 返回 `401 {totp_required: true}`,**不记失败**(这是正常两步流程的第一步,不是攻击信号)
4. 密码正确但 TOTP 验证失败 → 记一次失败,返回 `401`
5. 全部通过 → 清零退避状态,发 session cookie
#### 配置项
| 配置项 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `AUTH_LOGIN_THROTTLE_ENABLED` | boolCONFIG_FIELDS | `true` | 全局开关,关闭后整个退避机制 no-op |
| `AUTH_TRUST_FORWARDED_FOR` | bool`.env` 部署级) | `false` | `true` 时取 `X-Forwarded-For` 最左作为 client IP;**反代后必须显式开启**,否则反代 IP 全视为同一 IP,按 IP 退避失效 |
> **反代部署注意**`AUTH_TRUST_FORWARDED_FOR` 默认 `false`(直接用 socket IP)。部署在 nginx 等反代后面时,应设 `AUTH_TRUST_FORWARDED_FOR=true`,并确保反代正确设置 `X-Forwarded-For`。
---
### 2. CLI 逃生通道
#### 设计原则
CLI 通过 **直连本地 DB**`get_session_local()`)工作,**无需 HTTP 服务器运行、无需任何已存凭据**(密码、恢复码均不需要)。拿到服务器 CLI 权限本身就意味着对系统有完全控制,因此这是可接受且必须存在的最终逃生口。
CLI 只动 auth 相关行(`auth_users` 的密码/TOTP 字段、`auth_login_throttle``auth_recovery_code`),**绝不**触碰用户数据表(`location``poo_records``public_ip_state` 等)。
入口:`python -m scripts.admin_cli <command>`
#### 子命令
| 命令 | 用途 | 逃生场景 |
| --- | --- | --- |
| `reset-password <username> [--password <pwd>]` | 重置密码(不带 `--password` 则交互式输入,不回显) | 忘记密码 |
| `unlock [--all \| --ip <ip> \| --username <u>]` | 清 `auth_login_throttle` 行 | 被退避锁住(429)时解锁 |
| `disable-totp <username>` | 关停 TOTP(清 secret + 删全部恢复码),**零凭据可执行** | 恢复码全丢也能进 |
| `reissue-totp <username>` | 生成新 TOTP secret 并打印 `otpauth://` URI | 设备丢失、需重新注册 Authenticator |
| `list-admin` | 列出所有用户与状态列 | 排障用 |
#### 使用示例
```bash
# 重置密码(不带 --password 时交互式 prompt,不回显)
python -m scripts.admin_cli reset-password admin
# 重置密码(非交互,脚本里用)
python -m scripts.admin_cli reset-password admin --password "newpassword"
# 解锁所有退避
python -m scripts.admin_cli unlock --all
# 解锁特定 IP
python -m scripts.admin_cli unlock --ip 1.2.3.4
# 解锁特定 username
python -m scripts.admin_cli unlock --username admin
# 关停 TOTP(零凭据,最终逃生口)
python -m scripts.admin_cli disable-totp admin
# 重新发放 TOTP secret(打印新 otpauth:// URI,扫码重新注册)
python -m scripts.admin_cli reissue-totp admin
# 查看用户列表
python -m scripts.admin_cli list-admin
```
在 Docker 容器内执行:
```bash
docker compose exec app python -m scripts.admin_cli <command>
```
#### `reissue-totp` 语义说明
- 对**已启用** TOTP 的用户:新 secret **立即在登录时生效**,旧 Authenticator 生成的码立即失效;**无需**再走 web `enable` 步骤。
- 现有恢复码**不被删除**——恢复码是独立的随机哈希值,与 TOTP secret 无密码学绑定,reissue 后恢复码依然有效(仍可用于登录)。
- 如果需要完整清理(secret + 所有恢复码),使用 `disable-totp`
---
### 3. 可选 TOTP 二次验证
#### 设计
- TOTP 遵循 RFC 6238,使用 `pyotp` 库。
- 二维码在**前端**由 `qrcode.react` 渲染(后端只返回 `otpauth://` URI,不引图像依赖)。
- `totp_secret` 明文存库(与项目其他 secret 处理一致,靠数据库文件权限保护)。
- 恢复码以 Argon2 哈希存库,一次性(使用后标记 `used_at`)。
#### 启用流程
1. **setup**`POST /api/auth/totp/setup`):生成 pending secret,返回 secret、`otpauth://` URI、10 个明文恢复码(**仅此一次**);此时 `totp_enabled` 仍为 `false`
2. **扫码**:用 Authenticator App 扫前端渲染的二维码(或手动输入 secret)。
3. **enable**`POST /api/auth/totp/enable`):输入当前 6 位码确认 → `totp_enabled` 变为 `true`
4. 妥善保存恢复码(不会再次展示)。
#### 停用流程
**web 停用**`POST /api/auth/totp/disable`):需提供当前密码或当前 6 位 TOTP 码。成功后清 secret、删全部恢复码,恢复纯密码登录。
**CLI 停用**(逃生口,恢复码全丢时):`python -m scripts.admin_cli disable-totp admin`,零凭据,立即生效。
#### 登录二步流程
1. 提交 `POST /api/auth/login {username, password}` → 密码正确但 TOTP 已启用 → `401 {totp_required: true}`(不发 session
2. 前端切到第二屏,提交 `POST /api/auth/login {username, password, totp_code}` → 通过 → 发 session cookie
`totp_code` 可以是 6 位 TOTP 动态码,也可以是 `xxxx-xxxx` 格式恢复码(命中即消费,不可复用)。
#### API 端点
| 端点 | 用途 |
| --- | --- |
| `POST /api/auth/totp/setup` | 生成 pending secret + URI + 恢复码(一次性明文返回) |
| `POST /api/auth/totp/enable` | 带当前 6 位码确认启用 |
| `POST /api/auth/totp/disable` | 带密码或当前码停用 |
| `GET /api/auth/totp` | 返回当前 TOTP 状态(`{enabled: bool}`),不返回 secret/恢复码 |
全部端点需要 session cookie`GET /api/auth/totp` 不需 CSRF;其余写端点需 `X-CSRF-Token`)。
#### 配置项
| 配置项 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `AUTH_TOTP_ISSUER` | str`.env` 部署级) | 空(回退 `app_name` | 显示在 Authenticator App 里的 issuer 标签 |
---
## 受保护范围
当前 JSON API 端点(`/api/*`)需要 session cookie;写端点需额外携带 `X-CSRF-Token` header。
裸 ingestion 端点(`/location/record``/poo/record` 等设备调用端点)暂未收口到 session 保护(M3 计划引入 token 鉴权)。
## 下一步(不在当前范围)
- M3:token 鉴权(供脚本 / 设备 / 移动端调用 API),ingestion 端点收口。
+2
View File
@@ -5,6 +5,8 @@
- [`m1-db-consolidation.md`](./m1-db-consolidation.md) — 单库化地基 - [`m1-db-consolidation.md`](./m1-db-consolidation.md) — 单库化地基
- [`m2-frontend-v2.md`](./m2-frontend-v2.md) — React SPA 前端 v2 - [`m2-frontend-v2.md`](./m2-frontend-v2.md) — React SPA 前端 v2
- [`m3-token-mobile.md`](./m3-token-mobile.md) — token 鉴权与移动端(远期) - [`m3-token-mobile.md`](./m3-token-mobile.md) — token 鉴权与移动端(远期)
- [`m4-login-hardening.md`](./m4-login-hardening.md) — 登录加固(防爆破/指数退避 + CLI 逃生 + 可选 TOTP**先做**
- [`m5-iot-energy.md`](./m5-iot-energy.md) — IoT 集成与能耗采集(Modbus/Energy + MQTT/HA Discovery + 前端侧边栏)
本文件定义**所有任务共用的格式与协作规则**,三个里程碑文档不再重复这些约定。 本文件定义**所有任务共用的格式与协作规则**,三个里程碑文档不再重复这些约定。
+44 -26
View File
@@ -27,15 +27,16 @@
### 3.2 鉴权:复用 session cookie + SPA 版 CSRF ### 3.2 鉴权:复用 session cookie + SPA 版 CSRF
- 继续用现有 **HttpOnly session cookie**(同源自动携带),M2 **不引入 token**token 属 M3)。 - 继续用现有 **HttpOnly session cookie**(同源自动携带),M2 **不引入 token**token 属 M3)。
- CSRF:新增 `GET /api/session` 返回当前用户 + 该会话的 `csrf_token`SPA 在所有写请求(POST/PUT/PATCH/DELETE)放 `X-CSRF-Token` header,后端校验其与 session 内 `csrf_token` 一致。等价于把现有表单 CSRF 平移到 header - CSRF(已定·简化版):依赖 `SameSite=Lax` 的 session cookie——跨站发起的写请求(POST/PUT/PATCH/DELETE**不会自动携带 cookie**,经典 CSRF 主路已被堵;再要求所有写请求带一个**自定义 header**(跨站无 CORS 预检发不出,且本应用不对外站开放 CORS)作为纵深防御。**不做 per-session token 比对**(个人自用场景足够)。`GET /api/session` 仍保留,用途是返回当前登录用户、引导 SPA(不再以下发/校验 `csrf_token` 为目的)
- 浏览器面向的所有新端点一律 session 保护;**裸 ingestion 端点(设备调用的 `POST /location/record``POST /poo/record`)维持现状到 M3**。 - 浏览器面向的所有新端点一律 session 保护;**裸 ingestion 端点(设备调用的 `POST /location/record``POST /poo/record`)维持现状到 M3**。
### 3.3 前端工程 ### 3.3 前端工程
- `frontend/`**Vite + React + TypeScript**。 - `frontend/`**Vite + React + TypeScript**。
- API client:由后端 `openapi/openapi.json` **自动生成** TS 类型与请求函数(如 `openapi-typescript` + 轻量 fetch 封装,或同类工具)。生成物入库或在 build 时生成(见 T06 决策)。 - 组件库:**Mantine**(已定;批电池齐、TS 优先、视觉中性,最贴近此前 Vue 侧 Naive UI 的用法)。
- 可视化:地图 + 热力图(location 轨迹 / poo 点位)。建议 **MapLibre GL 或 Leaflet + heatmap 插件**(最终选型见 §5 决策) - API client:由后端 `openapi/openapi.json` **自动生成** TS 类型与请求函数(如 `openapi-typescript` + 轻量 fetch 封装)。**生成物入库** + `npm run codegen` + CI 校验"生成物与 openapi 同步"(已定)。fetch 封装统一带 cookie、写请求注入自定义 CSRF header、401 跳登录
- 状态/数据请求:轻量即可(如 TanStack Query),不引入重型框架 - 可视化:**Leaflet**(已定)—— `react-leaflet` + `leaflet.heat`(热力图,**头号功能**+ `leaflet.markercluster`(点多时聚合)+ OSM 栅格瓦片(零 key)。**地图封在一个自包含组件后面**(如 `<RecordsMap points mode onSelect>`,全应用只此处 import leaflet),数据获取/时间窗 state 在外面;这样将来若要换 **MapLibre GL** 是被隔离的局部重写,不波及其它
- 状态/数据请求:轻量即可(**TanStack Query**,已定),不引入重型框架。
### 3.4 构建与部署 ### 3.4 构建与部署
@@ -65,14 +66,31 @@
> 记录 CRUD 依赖现有 PK 作行标识(location PK=`person+datetime`poo PK=`timestamp`)。路径参数需对 `datetime`/`timestamp` 做 URL 编码处理。 > 记录 CRUD 依赖现有 PK 作行标识(location PK=`person+datetime`poo PK=`timestamp`)。路径参数需对 `datetime`/`timestamp` 做 URL 编码处理。
## 5. 需先拍板的决策(Orchestrator 在派 T06 前确认 ## 5. 已锁定决策(讨论后拍板
1. **地图/热力图库**MapLibre GL(矢量、现代)vs Leaflet(简单、生态大)。推荐 Leaflet + `leaflet.heat`(试水门槛低)。 > 以下为与项目所有者讨论后**已定**的选择。**线框图本里程碑不画**——按本节 + 各任务卡描述,由实现侧自行合理排版(含移动端布局)。
2. **OpenAPI client 生成物**:入库(确定性、便于 review)vs build 时生成(仓库干净)。推荐**入库**,并加一个 `npm run codegen` + CI 校验"生成物与 openapi 同步"。
3. **CSRF 落地**header `X-CSRF-Token` + `GET /api/session` 下发(推荐)vs 双提交 cookie。
4. **是否保留少量 Jinja**:建议 SPA 对齐后**全量移除** `templates/`,只留 SPA。
> 这些可用 1 个轻量"决策任务"或直接由 Orchestrator 在本节记录选择,再开 T06。 **技术选型**
1. **组件库 = Mantine**。批电池齐、TS 优先、视觉中性、文档好,最贴近此前 Naive UI 的用法,利于 agent 产出一致 UI。
2. **地图库 = Leaflet**`react-leaflet` + `leaflet.heat` + `leaflet.markercluster`,OSM 栅格、零 key)。**封在自包含组件后**,预留将来迁 MapLibre 的接缝(见 §3.3)。
3. **OpenAPI client = 生成物入库** + `npm run codegen` + CI 校验"与 openapi 同步"。
4. **CSRF = 简化版**`SameSite=Lax` cookie + 写请求带自定义 header**不做 per-session token**(见 §3.2)。
5. **前端栈**Vite + React + TS + TanStack Query + Mantine。
6. **Jinja**SPA 功能对齐后**全量移除** `templates/``pages.py`
**信息架构 / UX**
7. **首页主视图 = 地图(热力图为主)+ 时间范围选择器**。可视化优先级:**热力图(最重要)> 时间选择器(必须)> 散点点位/列表(辅助)**。
8. **列表 = 辅助页面,分页**(默认页大小 ~100、有上限;前端换页取数,不拉全量)。
9. **记录编辑/删除****location 靠点地图上的点**触发(不做 75k 行大列表);**poo 靠列表 + 地图点位**。
10. **配置入口**:config 作为普通页之一,由界面上一个**齿轮图标**进入。`/admin``/` 现状只是重定向到 `/config`SPA **不需要单独 admin 页**`/` 首页直接给地图主视图(概览 dashboard 列为**可选/后续**,非 M2 核心)。
11. **响应式 = 要**(手机浏览器可用、合理移动端布局)。**PWA** 列为近期 backlog(见 `docs/future-ideas.md`),M2 设计即按移动端友好铺路。
**范围边界**
12. **CRUD = 改非主键字段 + 删单行**;主键(location=`person+datetime`、poo=`timestamp`)**不可改**;**不提供 UI 新建**(记录由设备 ingestion 产生)。
13. **裸 ingestion 端点**`POST /location/record``POST /poo/record`)**维持现状到 M3**,本里程碑不加保护、不改动。
14. **trip / 轨迹连线**为**可选 / 后续**(5 分钟一点 + 手机记录较糙,先不做核心)。
> 项目定位:个人自用、家庭特化、不开源——设计可按单用户场景简化,不为通用性过度抽象。
## 6. 任务依赖图 ## 6. 任务依赖图
@@ -104,7 +122,7 @@
> 后端任务沿用 M1 的校验闸门(`pytest` / `ruff` / `export_openapi`)。前端任务的闸门见 §8。 > 后端任务沿用 M1 的校验闸门(`pytest` / `ruff` / `export_openapi`)。前端任务的闸门见 §8。
### M2-T01 — config JSON API ### M2-T01 — config JSON API
- **Status**: `todo` · **Depends**: noneM1 完成后) - **Status**: `done` · **Depends**: noneM1 完成后)
- **Context**: 把 `config_page` 的读写能力暴露成 JSON,复用现有 service,不重写业务逻辑。 - **Context**: 把 `config_page` 的读写能力暴露成 JSON,复用现有 service,不重写业务逻辑。
- **Files**: `create app/api/routes/api/config.py``create app/schemas/config.py``modify app/main.py`(注册路由);`create tests/test_api_config.py` - **Files**: `create app/api/routes/api/config.py``create app/schemas/config.py``modify app/main.py`(注册路由);`create tests/test_api_config.py`
- **Steps**: 用 `build_config_sections`/`save_config_updates` 包出 `GET/PUT /api/config`session 保护;secret 不回显、留空保留旧值语义照搬。 - **Steps**: 用 `build_config_sections`/`save_config_updates` 包出 `GET/PUT /api/config`session 保护;secret 不回显、留空保留旧值语义照搬。
@@ -116,7 +134,7 @@
- **Reviewer**: 复用了 service 而非复制逻辑;CSRF 校验存在;secret 不泄漏到响应或 OpenAPI 示例。 - **Reviewer**: 复用了 service 而非复制逻辑;CSRF 校验存在;secret 不泄漏到响应或 OpenAPI 示例。
### M2-T02 — session / auth JSON API ### M2-T02 — session / auth JSON API
- **Status**: `todo` · **Depends**: none - **Status**: `done` · **Depends**: none
- **Context**: 给 SPA 提供登录/注销/会话探测 + CSRF 下发。 - **Context**: 给 SPA 提供登录/注销/会话探测 + CSRF 下发。
- **Files**: `create app/api/routes/api/session.py``app/schemas/session.py``modify app/main.py``create tests/test_api_session.py` - **Files**: `create app/api/routes/api/session.py``app/schemas/session.py``modify app/main.py``create tests/test_api_session.py`
- **Steps**: `GET /api/session`401 或 user+csrf)、`POST /api/auth/login``POST /api/auth/logout``POST /api/auth/password`,复用 `app/services/auth.py` - **Steps**: `GET /api/session`401 或 user+csrf)、`POST /api/auth/login``POST /api/auth/logout``POST /api/auth/password`,复用 `app/services/auth.py`
@@ -129,7 +147,7 @@
- **Reviewer**: cookie 仍 HttpOnly、`Secure` 跟随 `app_env``SameSite=Lax`;密码仍 Argon2,不明文。 - **Reviewer**: cookie 仍 HttpOnly、`Secure` 跟随 `app_env``SameSite=Lax`;密码仍 Argon2,不明文。
### M2-T03 — 数据读取 APIlocations / poo / public-ip ### M2-T03 — 数据读取 APIlocations / poo / public-ip
- **Status**: `todo` · **Depends**: none - **Status**: `done` · **Depends**: none
- **Files**: `create app/api/routes/api/data.py``app/schemas/data.py``modify app/main.py``create tests/test_api_data.py` - **Files**: `create app/api/routes/api/data.py``app/schemas/data.py``modify app/main.py``create tests/test_api_data.py`
- **Steps**: `GET /api/locations`(时间范围 + 分页)、`GET /api/poo`(分页)、`GET /api/public-ip`state + history);session 保护;查询参数有上限防全表导出。 - **Steps**: `GET /api/locations`(时间范围 + 分页)、`GET /api/poo`(分页)、`GET /api/public-ip`state + history);session 保护;查询参数有上限防全表导出。
- **Acceptance**: - **Acceptance**:
@@ -139,7 +157,7 @@
- **Reviewer**: 查询走索引/PK,无 N+1;时间过滤边界正确。 - **Reviewer**: 查询走索引/PK,无 N+1;时间过滤边界正确。
### M2-T04 — 记录 CRUD API(修正 / 删除) ### M2-T04 — 记录 CRUD API(修正 / 删除)
- **Status**: `todo` · **Depends**: M2-T03 - **Status**: `done` · **Depends**: M2-T03
- **Files**: `modify app/api/routes/api/data.py``app/services/location.py``app/services/poo.py``create tests/test_api_record_crud.py` - **Files**: `modify app/api/routes/api/data.py``app/services/location.py``app/services/poo.py``create tests/test_api_record_crud.py`
- **Steps**: `PATCH`/`DELETE` locationPK person+datetime)与 pooPK timestamp);session + CSRF 保护;PK 路径参数 URL 解码;删除是**硬删单行**(不是清表)。 - **Steps**: `PATCH`/`DELETE` locationPK person+datetime)与 pooPK timestamp);session + CSRF 保护;PK 路径参数 URL 解码;删除是**硬删单行**(不是清表)。
- **Acceptance**: - **Acceptance**:
@@ -151,7 +169,7 @@
- **Reviewer**: 删除限定单 PK;编辑校验输入;ingestion 裸端点未被顺手加保护或改动。 - **Reviewer**: 删除限定单 PK;编辑校验输入;ingestion 裸端点未被顺手加保护或改动。
### M2-T05 — SMTP 测试 / 动作类 JSON API ### M2-T05 — SMTP 测试 / 动作类 JSON API
- **Status**: `todo` · **Depends**: M2-T01 - **Status**: `done` · **Depends**: M2-T01
- **Files**: `modify app/api/routes/api/config.py``modify tests/test_api_config.py` - **Files**: `modify app/api/routes/api/config.py``modify tests/test_api_config.py`
- **Steps**: `POST /api/config/smtp/test` 复用 `send_smtp_test_email`,返回结构化结果(success / config-error / failed)。 - **Steps**: `POST /api/config/smtp/test` 复用 `send_smtp_test_email`,返回结构化结果(success / config-error / failed)。
- **Acceptance**: - **Acceptance**:
@@ -159,7 +177,7 @@
- [ ] 校验闸门全绿。 - [ ] 校验闸门全绿。
### M2-T06 — 前端 scaffold + OpenAPI codegen `[structural]` ### M2-T06 — 前端 scaffold + OpenAPI codegen `[structural]`
- **Status**: `todo` · **Depends**: M2-T01..T05OpenAPI 已稳定) - **Status**: `done` · **Depends**: M2-T01..T05OpenAPI 已稳定)
- **Context**: 建 `frontend/` 工程与类型化 client 流水线,这是后续所有前端任务的地基。 - **Context**: 建 `frontend/` 工程与类型化 client 流水线,这是后续所有前端任务的地基。
- **Files**: `create frontend/`Vite+React+TS 脚手架、`package.json``tsconfig.json`、eslint、vitest、`.gitignore`)、`frontend/src/api/`codegen 产物 + fetch 封装,自动注入 `X-CSRF-Token`)、`frontend/README.md``npm run codegen` 脚本 - **Files**: `create frontend/`Vite+React+TS 脚手架、`package.json``tsconfig.json`、eslint、vitest、`.gitignore`)、`frontend/src/api/`codegen 产物 + fetch 封装,自动注入 `X-CSRF-Token`)、`frontend/README.md``npm run codegen` 脚本
- **Steps**: 初始化 Vite React-TS;接 `openapi/openapi.json` 生成类型;写一个最小 App 壳 + 受保护路由骨架;fetch 封装统一带 cookie、写请求注入 CSRF header、401 跳登录。 - **Steps**: 初始化 Vite React-TS;接 `openapi/openapi.json` 生成类型;写一个最小 App 壳 + 受保护路由骨架;fetch 封装统一带 cookie、写请求注入 CSRF header、401 跳登录。
@@ -170,24 +188,24 @@
- **Reviewer**: client 全部基于生成类型;CSRF/cookie/401 处理在统一封装层;无手写、与契约不符的请求类型。 - **Reviewer**: client 全部基于生成类型;CSRF/cookie/401 处理在统一封装层;无手写、与契约不符的请求类型。
### M2-T07 — 鉴权 UI(登录 / 会话引导 / 改密) ### M2-T07 — 鉴权 UI(登录 / 会话引导 / 改密)
- **Status**: `todo` · **Depends**: M2-T06 - **Status**: `done` · **Depends**: M2-T06
- **Acceptance**: 登录成功进受保护区;未登录访问受保护路由跳登录;强制改密流程可走完;`build/lint/typecheck/test` 全绿。 - **Acceptance**: 登录成功进受保护区;未登录访问受保护路由跳登录;强制改密流程可走完;`build/lint/typecheck/test` 全绿。
### M2-T08 — 配置 UI(取代 Jinja config 页) ### M2-T08 — 配置 UI(取代 Jinja config 页)
- **Status**: `todo` · **Depends**: M2-T06 - **Status**: `done` · **Depends**: M2-T06
- **Acceptance**: 能读/存所有现有配置 section;secret 不回显、留空保留;SMTP 测试按钮反映三态;前端闸门全绿。 - **Acceptance**: 能读/存所有现有配置 section;secret 不回显、留空保留;SMTP 测试按钮反映三态;前端闸门全绿。
### M2-T09 — 数据可视化 UI地图 + 热力图) ### M2-T09 — 数据可视化 UI(热力图为主的地图)
- **Status**: `todo` · **Depends**: M2-T06(数据来自 T03 - **Status**: `done` · **Depends**: M2-T06(数据来自 T03
- **Context**: 接管 Grafana 原职责location 轨迹/热力图、poo 点位 - **Context**: 接管 Grafana 原职责,且**首页主视图就是这张地图**。优先级:**① 热力图(最重要)② 时间范围选择器(必须)③ 散点点位(辅助,主要服务编辑/删除)**。location:去过哪的密度;poo:狗最爱在哪拉
- **Acceptance**: 地图渲染 location/poo 点;热力图层可切换;时间范围筛选生效;前端闸门全绿。 - **Acceptance**: 首页渲染热力图(location / poo);**时间范围选择器生效、只取窗口内数据**(不拉全量);散点层可切换、点选某点可进入编辑/删除(接 T10/T04);location 点多时聚合;响应式(手机浏览器可用);前端闸门全绿。
### M2-T10 — 记录管理 UI(按需展示 + 增删改) ### M2-T10 — 记录管理 UI(按需展示 + 增删改)
- **Status**: `todo` · **Depends**: M2-T06CRUD 来自 T04 - **Status**: `done` · **Depends**: M2-T06CRUD 来自 T04
- **Acceptance**: 列表分页展示 poo/location;可编辑、可删除单条并即时刷新;删除有二次确认;前端闸门全绿。 - **Acceptance**: 列表分页展示 poo/location;可编辑、可删除单条并即时刷新;删除有二次确认;前端闸门全绿。
### M2-T11 — FastAPI 托管 SPA + 移除 Jinja ### M2-T11 — FastAPI 托管 SPA + 移除 Jinja
- **Status**: `todo` · **Depends**: M2-T07, T08, T09, T10 - **Status**: `done` · **Depends**: M2-T07, T08, T09, T10
- **Files**: `modify app/main.py`(挂载 SPA 静态目录 + 非 `/api` 路径回退 `index.html`);`delete app/templates/``app/api/routes/pages.py`(功能对齐后);`modify tests`(移除 Jinja 页面测试,新增 SPA fallback 测试) - **Files**: `modify app/main.py`(挂载 SPA 静态目录 + 非 `/api` 路径回退 `index.html`);`delete app/templates/``app/api/routes/pages.py`(功能对齐后);`modify tests`(移除 Jinja 页面测试,新增 SPA fallback 测试)
- **Acceptance**: - **Acceptance**:
- [ ] `/config` 等路径返回 SPA`index.html`),`/api/*` 不被 fallback 吞掉,`/static`/资源正常。 - [ ] `/config` 等路径返回 SPA`index.html`),`/api/*` 不被 fallback 吞掉,`/static`/资源正常。
@@ -196,7 +214,7 @@
- **Reviewer**: fallback 不拦截 `/api``/docs``/openapi.json`、静态资源;未登录访问 API 仍 401(不是被 SPA 壳吞掉)。 - **Reviewer**: fallback 不拦截 `/api``/docs``/openapi.json`、静态资源;未登录访问 API 仍 401(不是被 SPA 壳吞掉)。
### M2-T12 — 多阶段 Dockerfile + CI/compose ### M2-T12 — 多阶段 Dockerfile + CI/compose
- **Status**: `todo` · **Depends**: M2-T11 - **Status**: `done` · **Depends**: M2-T11
- **Files**: `modify Dockerfile`node build 阶段 → 拷 `dist` 进 python 镜像);`modify .github/workflows/*`(加前端 build/lint/typecheck);`modify tests/test_deployment.py`(镜像断言更新) - **Files**: `modify Dockerfile`node build 阶段 → 拷 `dist` 进 python 镜像);`modify .github/workflows/*`(加前端 build/lint/typecheck);`modify tests/test_deployment.py`(镜像断言更新)
- **Acceptance**: - **Acceptance**:
- [ ] 镜像构建成功且运行镜像不含 node 运行时。 - [ ] 镜像构建成功且运行镜像不含 node 运行时。
@@ -204,7 +222,7 @@
- [ ] 校验闸门全绿。 - [ ] 校验闸门全绿。
### M2-T13 — 文档 + OpenAPI 收尾 ### M2-T13 — 文档 + OpenAPI 收尾
- **Status**: `todo` · **Depends**: M2-T12 - **Status**: `done` · **Depends**: M2-T12
- **Acceptance**: README 增"前端 v2"段(开发/构建说明);architecture 退役"不前后端分离"约束;roadmap 勾选 M2`openapi/` 已同步入库。 - **Acceptance**: README 增"前端 v2"段(开发/构建说明);architecture 退役"不前后端分离"约束;roadmap 勾选 M2`openapi/` 已同步入库。
--- ---
+309
View File
@@ -0,0 +1,309 @@
# M4 — 登录加固(Login Hardening
> 阅读前提:先读 [`README.md`](./README.md)(协作模型、任务卡格式、校验闸门、数据安全红线)。
> **排期:本里程碑排在 [M5](./m5-iot-energy.md) 之前。** 系统现为公网 + 仅密码 + 无限重试,是现成风险;M4 与 M5 零文件重叠,先堵这个洞再做 IoT。
## 1. 目标
给暴露在公网的单 admin 登录做纵深防御:
1. **防爆破 / 指数退避**:失败登录按指数增长延迟,拖垮暴力枚举;成功即清零。
2. **CLI 逃生通道**:纯命令行重置密码 / 解锁 / 关停 TOTP,**不依赖任何已存恢复凭据**——拿到服务器 CLI 权限的人本来就无所不能,所以这是可接受、且必须存在的最终逃生口(消除"锁死就彻底进不去"的死角)。
3. **TOTP 二次验证(可选配)**admin 可**自选启用** RFC 6238 TOTP;启用后登录需密码 + 6 位动态码;提供一次性恢复码;CLI 能在恢复码全丢时关掉 TOTP。**非强制**,不启用则维持纯密码登录。
> 三段顺序:A(防爆破,紧急、可独立先发版)→ B(TOTP,可选配)→ 收尾。Phase A 自成可用增量,Phase B 失败不影响 A。
## 2. 现状(实现者可据此工作,不必通读全仓库)
**单 admin + Argon2**
- `app/models/auth.py``AuthUser``id / username unique / password_hash / is_active / force_password_change / created_at`)、`AuthSession``token_hash / csrf_token / expires_at / revoked_at`)。单库 `Base`
- `app/services/auth.py`
- `authenticate_user(session, *, username, password) -> AuthUser | None`:查用户→`verify_password`Argon2);失败只 `logger.info`**无任何节流**。
- `create_session(session, *, user, settings) -> (AuthSession, raw_token)``secrets.token_urlsafe`SHA256 存 `token_hash`TTL `auth_session_ttl_hours`(默认 12h)。
- `change_password(...)``get_authenticated_session(...)``initialize_auth_schema(session, settings)`**仅当无任何用户时**用 `AUTH_BOOTSTRAP_USERNAME/PASSWORD` 建初始 admin)。
- `app/api/routes/api/session.py`
- `POST /api/auth/login`body `{username, password}`):`authenticate_user` → None 则 401;否则 `create_session` + `set_cookie`(HttpOnly/SameSite=Lax/`auth_cookie_secure`) → 返回 `{user, csrf_token}`
- `GET /api/session`401 或 user+csrf)、`POST /api/auth/logout`CSRF)、`POST /api/auth/password`CSRF + 校验当前密码)。
**配置**
- `app/config.py``auth_bootstrap_username/password``auth_session_cookie_name``auth_session_ttl_hours``auth_cookie_secure_override`computed `auth_cookie_secure`)。
- 配置系统:扁平 KV`CONFIG_FIELDS` 注册表 + `Settings` + `_settings_payload`),新增标量配置项前端自动渲染(见 M5 文档 §2)。
**无现成 CLI**
- `scripts/``run_migrations.py` / `app_db_adopt.py` / `migrate_legacy_data.py`(argparse) / `export_openapi.py`。**没有**任何 admin/密码/auth 的 CLI。
- `pyproject.toml`**无 `console_scripts`**。CLI 入口沿用 `python -m scripts.<mod>` 风格(与 `python -m scripts.run_migrations` 一致)。
**前端登录(单步)**
- `frontend/src/pages/LoginPage.tsx``POST /api/auth/login {username,password}` → 401 显示通用错误;成功存 csrf、跳转。
- `frontend/src/auth/SessionProvider.tsx``GET /api/session` 引导。
- 无"锁定/稍后再试"提示、无二步流程。
**测试**
- `tests/test_api_session.py`(凭据/cookie/登出/改密/强制改密);`tests/conftest.py` 用 env 设 bootstrap 凭据。
## 3. 目标架构
### 3.1 防爆破 / 指数退避
- **状态表 `auth_login_throttle`**:按 key 记失败窗口(`key / scope / failures / first_failed_at / last_failed_at / next_allowed_at`),成功即删该 key 的行。
- **双键**:同时按 **client IP****username** 计;本次请求需等待 = 两者退避的**较大值**。
- 按 IP:堵单点暴力(attacker IP 越敲越慢,合法用户换 IP 不受影响)。
- 按 username:单 admin 的全局兜底(即便分布式换 IP 也有一层)。
- **DoS 权衡**:单 admin 下纯按 username 硬锁会被攻击者故意失败把你锁死,所以退避是**延迟(429 + Retry-After)而非永久锁定**,且 CLI 是最终逃生口。
- **client IP 来源**:默认用 socket IP`AUTH_TRUST_FORWARDED_FOR=true` 时取 `X-Forwarded-For` 最左(部署在反代后才开)。**这点必须显式配置**,否则反代后所有请求同一 IP,按 IP 退避失效。
- **指数公式(默认,常量可后调)**:前 `N_free=3` 次不延迟;之后 `wait = min(cap, base * 2^(failures - N_free))``base=1s``cap=900s`
- **接入点**`POST /api/auth/login` 开头先查退避 → 在窗口内直接 429(带 `Retry-After`),**不验密码**(省 Argon2、防枚举);验密码失败 → 记一次失败;成功 → 清该 IP + username 的退避行。
- **全局开关** `AUTH_LOGIN_THROTTLE_ENABLED`CONFIG_FIELDS,默认 true)。
### 3.2 CLI 逃生通道
`scripts/admin_cli.py``python -m scripts.admin_cli <cmd>`,argparse 子命令;直接连本地 DB,无网络,复用 `get_session_local()` + 模型 + Argon2 hasher):
| 命令 | 作用 |
| --- | --- |
| `reset-password <username> [--password ...]` | 重置密码(不给则交互式 prompt,不回显);逃生:忘密码 |
| `unlock [--all | --ip <ip> | --username <u>]` | 清 `auth_login_throttle` 行;逃生:被退避锁住 |
| `disable-totp <username>` | 关 TOTP(清 secret + 恢复码);逃生:**恢复码全丢也能进**(Phase B 才有意义)|
| `reissue-totp <username>` | 重新发放 TOTP secret(可选)|
| `list-admin` | 列用户/状态(排障)|
- **数据安全**:CLI 只动 auth 状态行(人工执行的 admin 动作),**绝不**触碰用户数据表(location/poo/energy 等),不 drop 有数据的表。
### 3.3 TOTP 二次验证(可选配)
- **库**`pyotp`(Python 侧);二维码在**前端**用 JS 库(`qrcode.react`)从 `otpauth://` URI 渲染——后端不引图像依赖。
- **存储**
- `AuthUser` 增列 `totp_secret`(nullable)、`totp_enabled`(bool, default false)。secret 落库明文(与现有 secret 处理一致,靠文件权限保护;文档注明)。
- `auth_recovery_code` 表(`user_id / code_hash / used_at`):恢复码**哈希**存(复用 Argon2 hasher),一次性。
- **启用流程(opt-in**
1. `POST /api/auth/totp/setup`(已登录)→ 生成 pending secret + `otpauth://` URI + 一组恢复码(**仅此一次明文返回**),尚未启用。
2. `POST /api/auth/totp/enable`(带一个当前 6 位码确认)→ `totp_enabled=true`,持久化恢复码哈希。
3. `POST /api/auth/totp/disable`(带密码或当前码)→ 关闭、清 secret/恢复码。
- **登录二因子(单端点、无独立 challenge token,保持无状态)**
- `POST /api/auth/login` body 增可选 `totp_code`
- 密码通过且 `totp_enabled`:无 `totp_code` → 返回 401 + `{totp_required: true}`**不发 session**);有 `totp_code` → 校验 TOTP **或**恢复码(命中恢复码则消费它)→ 通过才发 session。
- 未启用 TOTP:维持现状单步。
- 退避(§3.1)对两步都生效。
- **issuer 标签**`AUTH_TOTP_ISSUER`(默认 `app_name`),显示在 Authenticator app 里。
### 3.4 前端
- **登录页两步**`LoginPage` 在收到 `401 + totp_required` 时切到第二屏(6 位码输入,也接受恢复码),再次 `POST /api/auth/login``totp_code`。被退避(429)显示"稍后再试"(用 `Retry-After`)。
- **设置页 TOTP 区**:未启用→「启用」走 setup(展示二维码 + 恢复码一次性、要求输入码确认);已启用→「停用」。恢复码只在生成时展示一次,提示妥善保存。
## 4. API 契约(M4 要落地的端点)
> 全部 `/api`、写端点 session + CSRF 保护、JSON 进出;schema 经 `export_openapi.py` 固化。
| 分组 | 端点 | 用途 |
| --- | --- | --- |
| 登录 | `POST /api/auth/login` | 加退避(429+Retry-After);body 增可选 `totp_code`;启用 TOTP 且缺码→401 `{totp_required:true}` |
| TOTP | `POST /api/auth/totp/setup` | 生成 pending secret + otpauth URI + 恢复码(一次性)|
| TOTP | `POST /api/auth/totp/enable` | 带当前码确认启用 |
| TOTP | `POST /api/auth/totp/disable` | 关闭 TOTP |
| TOTP | `GET /api/auth/totp` | 返回当前 TOTP 状态(enabled 与否)|
> CLI 不是 HTTP 端点。throttle 开关与 issuer 走现有 `GET/PUT /api/config`(仅加 CONFIG_FIELDS)。
## 5. 已锁定决策(讨论后拍板)
1. **M4 独立里程碑、排在 M5 之前**
2. **范围**:防爆破 + 指数退避 + CLI 重置(密码/解锁/关 TOTP)+ TOTP(**可选配,非强制**)。
3. **退避双键(IP + username)、延迟非永久锁定、成功清零、CLI 兜底**;反代后需开 `AUTH_TRUST_FORWARDED_FOR`
4. **CLI 逃生通道不依赖任何已存恢复凭据**;只动 auth 行,不碰用户数据。
5. **TOTP 单端点二因子**login 加可选 `totp_code`,无独立 challenge token);恢复码哈希存、一次性;secret 明文存(与现有一致)。
6. **二维码前端渲染**`qrcode.react`),后端只给 `otpauth://` URI + secretPython 仅加 `pyotp`
7. **CLI 入口 = `python -m scripts.admin_cli`**(无 console_scripts)。
> 项目定位:个人自用、单 admin——可按单用户简化,不为多租户/找回邮件等过度设计。
## 6. 任务依赖图
```
Phase A(防爆破,紧急,自成可发版增量)
M4-T01 [schema] auth_login_throttle 表 + 模型
└─► M4-T02 退避 service + 接入 login429/Retry-After/成功清零)
└─► M4-T03 CLI 骨架 + reset-password + unlock
Phase BTOTP,可选配)
M4-T04 [schema] AuthUser TOTP 字段 + auth_recovery_code 表
├─► M4-T05 TOTP service + setup/enable/disable/status API
├─► M4-T06 登录二因子(login 加 totp_code;依赖 T02 已改过 login
└─► M4-T07 CLI disable-totp / reissue-totp
M4-T08 前端:两步登录 + 设置页 TOTP(依赖 T05+T06;引入 qrcode.react
收尾
M4-T09 文档 + OpenAPI + roadmap(依赖全部)
```
`T01` 可先开。Phase A(T01–T03)跑完即可发一版只含防爆破 + CLI 的安全增量。
---
## 7. 原子任务(任务卡)
> 后端沿用校验闸门(`pytest`/`ruff`/改路由或 schema 则 `export_openapi` 重导出入库)。前端闸门见 §8。新增依赖(`pyotp`、`qrcode.react`)须同步 `requirements.in/.txt` 与 `frontend/package.json` 重新锁定。
### M4-T01 — `auth_login_throttle` 表 + 模型 `[schema]`
- **Status**: `done` · **Depends**: none
- **Context**: 存按 IP / username 的失败退避状态。仅建 schema。
- **Files**: `create app/models/auth_throttle.py`(或并入 `app/models/auth.py`);`create alembic_app/versions/<date>_NN_auth_login_throttle.py``modify alembic_app/env.py``scripts/app_db_adopt.py`baseline 常量);`create tests/test_auth_throttle_model.py`
- **Steps**: 模型 `LoginThrottle``id / key str / scope str('ip'|'user') / failures int / first_failed_at / last_failed_at / next_allowed_at``(scope,key)` unique 索引);新 revision 建表 + 索引;更新 baseline 常量。
- **Out of scope / 不要碰**: 不写退避逻辑(T02);不动登录端点。
- **Acceptance criteria**:
- [ ] 临时库 upgrade 到 head 含该表与唯一索引;`downgrade -1` 干净。
- [ ] `APP_BASELINE_REVISION` 更新;`env.py` 已 import。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: `(scope,key)` 唯一;链上单 head;env.py 注册模型。
### M4-T02 — 退避 service + 接入登录
- **Status**: `done` · **Depends**: M4-T01
- **Context**: 指数退避核心 + 接 `POST /api/auth/login`
- **Files**: `create app/services/login_throttle.py``modify app/api/routes/api/session.py``app/services/config_page.py`(+`CONFIG_FIELDS` `AUTH_LOGIN_THROTTLE_ENABLED`)、`app/config.py`(+`auth_login_throttle_enabled``auth_trust_forwarded_for`)`modify tests/test_api_session.py``create tests/test_login_throttle.py`
- **Steps**:
1. `check_and_get_wait(session, *, ip, username) -> int`(秒;>0 表示仍在窗口);`register_failure(...)`(更新双键 failures/next_allowed_at,按 §3.1 公式);`clear(session, *, ip, username)`(成功清零)。
2. 登录端点:取 client IP(按 `auth_trust_forwarded_for` 决定是否信 XFF 最左);**先**查退避,>0 → 429 + `Retry-After`,不验密码;验密码失败 → `register_failure` 后 401;成功 → `clear` 再发 session。
3. 开关关闭时整段 no-op。
- **Out of scope / 不要碰**: 不做 TOTPPhase B);不改 logout/password 端点。
- **Acceptance criteria**:
- [ ] 单测:连续失败后 `wait` 按指数增长且封顶;窗口内请求 429 带 `Retry-After` 且不验密码。
- [ ] 单测:成功登录清零;下次无延迟。
- [ ] 401 仍为通用文案(不泄露用户是否存在);现有登录测试仍绿。
- [ ] 开关 false 时无节流。
- [ ] 校验闸门全绿(OpenAPI 若变重导出)。
- **Reviewer checklist**: 退避在验密码**之前**生效;双键较大值;XFF 仅在配置开启时信任;无把密码写进日志。
### M4-T03 — CLI 骨架 + reset-password + unlock
- **Status**: `done` · **Depends**: M4-T01
- **Context**: 逃生通道第一批命令。
- **Files**: `create scripts/admin_cli.py``create tests/test_admin_cli.py`
- **Steps**: argparse 子命令;`reset-password <username> [--password|prompt(getpass)]` 用 Argon2 hasher 重置 `password_hash``unlock [--all|--ip|--username]``auth_login_throttle` 行;`list-admin`;复用 `get_session_local()` + 模型;无网络。
- **Out of scope / 不要碰**: 不动 HTTP 端点;不碰用户数据表;TOTP 命令在 T07。
- **Acceptance criteria**:
- [ ] 单测:`reset-password` 后新密码可 `verify_password` 通过、旧密码失败。
- [ ] 单测:`unlock` 清掉指定/全部退避行。
- [ ] 不存在的 user 友好报错、非零退出。
- [ ] `grep` 确认 CLI 不含对用户数据表的删除/drop。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: 密码可交互输入且不回显;只动 auth 行;幂等可重跑。
### M4-T04 — AuthUser TOTP 字段 + `auth_recovery_code` 表 `[schema]`
- **Status**: `done` · **Depends**: none(与 Phase A 并行的 schema,但 T06 依赖 T02
- **Files**: `modify app/models/auth.py``AuthUser.totp_secret`/`totp_enabled`);`create` 模型 `RecoveryCode``create alembic_app/versions/<date>_NN_totp.py``modify alembic_app/env.py``scripts/app_db_adopt.py``create tests/test_totp_models.py`
- **Steps**: `totp_secret` nullable、`totp_enabled` bool default false`auth_recovery_code(user_id FK, code_hash, used_at nullable)`revision 建列 + 表;更新 baseline。
- **Out of scope / 不要碰**: 不写 TOTP 逻辑(T05)。
- **Acceptance criteria**:
- [ ] upgrade/downgrade 干净;默认 `totp_enabled=false``totp_secret=null`
- [ ] baseline 常量更新;env.py import。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: 现有用户迁移后默认未启用 TOTP(不破坏现有登录)。
### M4-T05 — TOTP service + setup/enable/disable/status API
- **Status**: `done` · **Depends**: M4-T04
- **Files**: `create app/services/totp.py``app/schemas/totp.py``modify app/api/routes/api/session.py`(或新 `auth_totp.py` 路由)、`app/config.py`(+`auth_totp_issuer`)`modify requirements.in/.txt`(+`pyotp`)`create tests/test_api_totp.py`
- **Steps**: pyotp 生成 secret + `otpauth://` URIissuer=`auth_totp_issuer`);恢复码生成(N=10、`xxxx-xxxx`Argon2 哈希存、明文仅 setup 返回一次);`setup`pending,不启用)/`enable`(带当前码确认)/`disable`(带密码或当前码)/`GET status`;全部 session+CSRF。
- **Out of scope / 不要碰**: 不改 login 校验(T06)。
- **Acceptance criteria**:
- [ ] `setup` 返回 secret+URI+恢复码(一次性);未确认前 `totp_enabled` 仍 false。
- [ ] `enable` 用错码失败、对码成功;恢复码以哈希持久化。
- [ ] `disable` 关闭并清 secret/恢复码。
- [ ] schema 经 OpenAPI 固化;secret/恢复码不回显于 `GET status`
- [ ] 校验闸门全绿。
- **Reviewer checklist**: 恢复码只存哈希、明文仅一次;secret 不进 OpenAPI 示例/日志;`requirements.txt` 锁定 `pyotp`
### M4-T06 — 登录二因子(login 加 `totp_code`
- **Status**: `done` · **Depends**: M4-T05, M4-T02
- **Files**: `modify app/api/routes/api/session.py``app/schemas/session.py``LoginRequest.totp_code` 可选)、`app/services/totp.py`(校验 + 消费恢复码);`modify tests/test_api_session.py``tests/test_api_totp.py`
- **Steps**: 密码通过后:若 `totp_enabled` 且无 `totp_code` → 401 `{totp_required:true}`(不发 session、退避照算);有 `totp_code` → 校验 TOTP 或恢复码(命中恢复码置 `used_at`)→ 通过发 session;未启用 → 现状。
- **Out of scope / 不要碰**: 不改 setup/enableT05);不动退避公式(T02)。
- **Acceptance criteria**:
- [ ] 未启用 TOTP 的用户登录与现状完全一致。
- [ ] 启用后:缺码→401 totp_required;错码→401;对码→发 session;恢复码可用且一次性。
- [ ] 退避对二步同样生效。
- [ ] OpenAPI 重导出入库;校验闸门全绿。
- **Reviewer checklist**: `totp_required` 时确未发 cookie;恢复码消费后不可复用;通用错误不泄露细节。
### M4-T07 — CLI disable-totp / reissue-totp
- **Status**: `done` · **Depends**: M4-T04(逻辑上配合 T03 的 CLI 骨架)
- **Files**: `modify scripts/admin_cli.py``modify tests/test_admin_cli.py`
- **Steps**: `disable-totp <username>``totp_enabled=false` + 清 secret + 删恢复码(**不需任何恢复码即可执行**);`reissue-totp <username>`:生成新 secret(可选打印新 URI)。
- **Out of scope / 不要碰**: 不碰用户数据表。
- **Acceptance criteria**:
- [ ] 单测:启用 TOTP 的用户经 `disable-totp` 后可纯密码登录(结合 T06 行为)。
- [ ] 不依赖任何恢复码即可关停。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: 逃生不依赖已存凭据;只动 auth 行。
### M4-T08 — 前端:两步登录 + 设置页 TOTP
- **Status**: `done` · **Depends**: M4-T05, M4-T06
- **Files**: `modify frontend/src/pages/LoginPage.tsx``create frontend/src/pages/.../TotpSettings.tsx`(或并入 ConfigPage)、`frontend/src/auth/totp.ts``modify frontend/package.json`(+`qrcode.react`lock 同步)`create` 对应测试
- **Steps**: 登录页:401+`totp_required`→第二屏 6 位码(也接受恢复码);429→"稍后再试"(读 `Retry-After`)。设置页:未启用→启用流程(二维码由 `otpauth` URI 渲染 + 恢复码一次性展示 + 输码确认);已启用→停用。
- **Out of scope / 不要碰**: 不改后端;不碰防爆破逻辑。
- **Acceptance criteria**:
- [ ] 未启用 TOTP 的登录体验不变;启用后两步可走通;恢复码可登录。
- [ ] 二维码可被 Authenticator 扫描;恢复码仅展示一次。
- [ ] 429 有"稍后再试"提示。
- [ ] 前端闸门全绿。
- **Reviewer checklist**: 全走类型化 clientsecret/恢复码不落 localStorage/日志;空/错/加载态有处理。
### M4-T09 — 文档 + OpenAPI + roadmap 收尾
- **Status**: `done` · **Depends**: 全部
- **Files**: `modify README.md``docs/auth.md`(防爆破 + CLI + TOTP 说明、CLI 用法)、`docs/architecture-overview.md``modify docs/roadmap.md`(把"TOTP 二次验证"与新"登录防爆破"从「下一阶段」毕业、加 M4 行、注明 M4 先于 M5);`modify docs/design/README.md`(已含 m4);`run python scripts/export_openapi.py` 提交 `openapi/`
- **Acceptance criteria**:
- [ ] 文档含 CLI 逃生用法与 TOTP 启停说明;`git diff --exit-code openapi/` 无差异。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: roadmap 反映 M4 已落地且先于 M5;无残留旧描述。
---
## 8. 前端校验闸门(前端任务每次结束都要全绿)
`frontend/` 下:`npm ci && npm run lint && npm run typecheck && npm run test && npm run build`。新增依赖 `qrcode.react` 须提交 `package.json` + `package-lock.json`。后端同任务改路由/schema 仍需根目录 `export_openapi.py` 并提交 `openapi/`
## 9. 构建上下文 / 依赖完整性
- 新增 Python 依赖 `pyotp`、前端 `qrcode.react`:同步重新锁定 `requirements.txt` / `package-lock.json`,否则镜像缺包。
- 新文件均在既有 `app/``scripts/``frontend/` 的 COPY 范围内;`scripts/admin_cli.py` 须在镜像里可 `python -m scripts.admin_cli` 调用(确认 `scripts/` 已进镜像)。
- `tests/test_deployment.py::test_dockerfile_copy_sources_exist` 仍应通过。
## 10. 人工验收 walkthrough(实现完成后)
> 重点:连续输错 → 系统自动退避锁住 → CLI 解锁后恢复登录。可用 `docker compose` 起服务,CLI 在容器内或容器外跑均可。
**准备**:起服务(任选其一)
- Docker`docker compose up -d`(应用容器名见 `docker-compose.yml`,下文记为 `<app>`)。
- 或本地:`source .venv/bin/activate && uvicorn app.main:app --port 8000`
**1) 触发退避锁定** —— 用**错误密码**连续登录同一账号:
```bash
for i in $(seq 1 8); do \
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:8000/api/auth/login \
-H 'Content-Type: application/json' -d '{"username":"admin","password":"wrong"}'); \
echo "尝试 $i -> HTTP $code"; done
```
- 预期:前几次 `401`,超过免费次数后变 `429`(被锁/退避)。单独跑一次带 `-i` 可看到 `Retry-After` 头逐次增大(指数退避)。
- 在退避窗口内,**即使输正确密码**也应被 `429` 挡住(验证锁定确实生效)。
**2) CLI 解锁**
- 容器内:`docker compose exec <app> python -m scripts.admin_cli unlock --all`
- 或容器外:`python -m scripts.admin_cli unlock --all`
- 预期:打印清除的退避条目数。
**3) 验证恢复** —— 立即用**正确密码**登录:
```bash
curl -i -X POST http://localhost:8000/api/auth/login \
-H 'Content-Type: application/json' -d '{"username":"admin","password":"<正确密码>"}'
```
- 预期:`200``Set-Cookie` 下发 session → 解锁成功。
**4)(可选)其它逃生命令**
- 重置密码:`python -m scripts.admin_cli reset-password admin`(交互输入新密码、不回显)。
- 关停 TOTP(若已启用):`python -m scripts.admin_cli disable-totp admin`,随后纯密码即可登录。
## 11. 里程碑完成定义(DoD)
- 登录失败按指数退避(429 + Retry-After),成功清零;可经 `AUTH_LOGIN_THROTTLE_ENABLED` 开关。
- `python -m scripts.admin_cli` 能在**无任何 Web 访问、无恢复码**的情况下重置密码 / 解锁 / 关停 TOTP。
- TOTP 可由 admin 自选启用:启用后两步登录、恢复码可用且一次性;不启用维持纯密码。
- 后端 `pytest`/`ruff`/`export_openapi` + 前端 `lint/typecheck/test/build` 全绿且 `openapi/` 入库。
- README/auth/architecture/roadmap 反映 M4 现实与"先于 M5"的排期。
+533
View File
@@ -0,0 +1,533 @@
# M5 — IoT 集成与能耗采集(Modbus 设备采集 + MQTT/HA Discovery + 前端侧边栏)
> 阅读前提:先读 [`README.md`](./README.md)(协作模型、任务卡格式、校验闸门、数据安全红线)。本里程碑建立在 M1 单库 + M2 React SPA 之上。
> 配套参考:电表协议见 [`../references/SDM120-Modbus-Protocol.md`](../references/SDM120-Modbus-Protocol.md)。
## 1. 目标
给后端接入家庭 IoT 生态,并新增一条通用的 Modbus 设备采集链路(首个落地的领域是能耗):
1. **Modbus 设备采集(通用管道)**:通过 Modbus-TCP 网关周期读取挂在网关后面的 Modbus slave 设备(首个 profile 为 SDM120 单相电表),按设备的 **YAML profile** 解码为工程量,存入单库的**通用读数表**。后台静默轮询,支持多设备、多 profile。
2. **MQTT + Home Assistant Discovery**:后端作为 MQTT 发布方,按"**可勾选暴露**"的方式把数据以 HA Discovery 自动注册成 device/entity(不止 sensor)。
3. **前端侧边栏 + Energy 视图**:把现有顶栏改成侧边导航,承载首个领域视图 **Energy**(设备管理 + 最新读数 + 走势图)。
> **命名分层(本里程碑的核心决策)**:**存储 / 采集 / API 是通用的 `modbus_*`**(不锁死在"电表"上,以后接别的 Modbus 设备无需改表);**面向用户的呈现是领域特定的**——本里程碑只落第一个领域视图 **Energy**,消费通用 device 数据、用电表视角展示。
> 三段有依赖关系,按 §6 的 `Depends` 顺序推进:A(侧边栏,独立)→ B(Modbus 采集后端 + Energy 前端)→ CMQTT/Discovery,消费 B 的数据)。
## 2. 现状(实现者可据此工作,不必通读全仓库)
**单库数据层**M1 完成态)
- `app/db.py``class Base(DeclarativeBase)`,绑 `settings.app_database_url` 的 cached engineWAL 已开),`get_engine` / `get_session_local` / `reset_db_caches` / `get_db_session`
- 模型都继承同一 `Base``app/models/{auth,config,public_ip,location,poo}.py`
- 单 Alembic 链 `alembic_app/`head = `20260611_06_merge_location_poo_tables`(见 `alembic_app/versions/`);`alembic_app/env.py` 逐个 import 所有模型。
- 迁移命名惯例:`YYYYMMDD_NN_<desc>.py``revision` / `down_revision` 串链。
- `scripts/app_db_adopt.py` 常量 `APP_BASELINE_REVISION` 指向当前 head`scripts/run_migrations.py` 负责把 app 库升到 head。
**配置系统(扁平 KV,自动渲染)**
- `app/config.py``class Settings(BaseSettings)`,每个配置项一个带类型的字段 + 默认值。
- `app/services/config_page.py``CONFIG_FIELDS: tuple[ConfigField, ...]` 是**注册表**`section / env_name / setting_attr / label / secret / input_type`);`build_config_sections`(读,secret 回空串)、`save_config_updates`(写,空 secret 保留旧值)、`build_runtime_settings`DB override 合并进 Settings)、`_settings_payload`(把 Settings 摊平成 dict,新字段要在此补一行)。
- `app/api/routes/api/config.py``GET/PUT /api/config`session + CSRF 保护;非法值 422 且不写库。
- 前端 `frontend/src/pages/ConfigPage.tsx`**通用渲染**——按 section 分组、按 `input_type`/`secret` 渲染输入框。**新增标量配置项零前端改动**(追加 `CONFIG_FIELDS` + `Settings` 字段 + `_settings_payload` 一行即可)。
- ⚠️ 扁平 KV **装不下"设备列表"和"逐实体勾选"**——这两者走专用表 + 专用 API + 自定义 UI(见 §3.2 / §3.4)。
**后台调度(APScheduler**
- `app/main.py` lifespan`BackgroundScheduler(timezone="UTC")``scheduler.add_job(_run_scheduled_public_ip_check, IntervalTrigger(hours=4), id=..., max_instances=1, coalesce=True)``scheduler.start()``yield``scheduler.shutdown(wait=False)`
- 周期任务惯例:一个**同步** wrapper 自己开/关 session`session = get_session_local()(); try: service(session, ...) finally: session.close()`service 内部不抛崩溃。
**Home Assistant 现状(REST,不碰 MQTT**
- `app/integrations/homeassistant.py``HomeAssistantClient.publish_sensor()``POST /api/states/{entity}`)、`trigger_webhook()`
- 入站 webhook `app/api/routes/homeassistant.py``POST /homeassistant/publish`envelope `target/action/content`)。
- 新 MQTT Discovery 与此**并行、不冲突**,是第二条独立通道。
**前端(M2**
- React + react-router v6 + Mantine + TanStack Query + `openapi-fetch` 生成的类型化 client`frontend/src/api/client.ts` + `schema.d.ts`)。
- `frontend/src/App.tsx``AppLayout`(当前是**顶栏**),包住所有受保护页;路由 `/`(HomePage 地图)、`/config``/records``/login``/change-password` 不带 layout。
- 数据请求惯例:`useQuery`/`useMutation` + `apiClient.GET/POST/...`(见 `frontend/src/records/hooks.ts`)。
- **无图表库**(只有 Leaflet 地图);走势图需新引入 **Recharts**
## 3. 目标架构
### 3.0 两层数据模型(本里程碑的地基决策)
把"设备是什么、怎么读"与"读到了什么"彻底分成两层,**协议知识与部署信息分离**:
```
┌─────────────────────────────────────────────────────────────┐
│ 协议层(固定,随代码走) 部署层(可配置,落 DB) │
│ ── YAML profile ────────── ── modbus_device 行 ────── │
│ • 读哪些寄存器(FC/地址/块) • friendly_name(可改) │
│ • 每个量:key / 类型 / 解码 • host / port(网关) │
│ • 每个量:unit / device_class • unit_id=电表 Meter ID
│ / ha_component 设备上可设,故落 DB) │
│ • 唯一真相源,喂给"读/存/HA注册" • profile 名(选哪个 YAML
│ • uuid / poll_interval / enabled│
└─────────────────────────────────────────────────────────────┘
▲ 多个 device 可共享同一 profile │
└─────────────────────────────────────────┘
│ 采集
── modbus_reading 行(通用遥测)──
device_id · recorded_at · payload(JSON blob)
```
- **profile = 仓库内只读 YAML**(如 `app/integrations/modbus/profiles/sdm120.yaml`),随镜像打包、纳入版本控制,**只描述固定协议知识**:读哪些寄存器、怎么解码、每个量的 `key`/`unit`/`device_class`/`ha_component`。**绝不**放 friendly_name / unit_id 这类每设备各异的可配置项。
- **可配置项落 `modbus_device` 行**,由前端 UI 设置:friendly_name、host、port、**unit_idModbus 从机地址,设备面板上可改)**、选用的 profile 名、poll_interval、enabled。
- **多设备共享一个 profile**:例如两块 SDM120 都 `profile="sdm120"`,一块 friendly_name="SDM120 空调"、unit_id=1,另一块 friendly_name="SDM120 服务器"、unit_id=2——共用同一份解码规则,部署参数各自独立。
- **读数存通用 JSON `payload`**,不再用固定列。profile 是解释器:知道 payload 里有哪些 key、各自单位与 device_class。
- 聚合不必搬到后端硬算:**SQLite 用 `json_extract` 在 SQL 端做 `AVG`/`MAX`/`GROUP BY`**(走势图都是带时间窗的查询,被 `(device_id, recorded_at)` 索引圈住,只扫窗内行)。
- 真有某个量需要高频聚合 → 后续给该量加 **generated column + 表达式索引**(非破坏性,要哪个补哪个),无需一开始把表锁成固定列。
### 3.1 Modbus 采集
- **传输:仅 Modbus-TCP**(用户的 Waveshare RTU↔TCP 网关,RJ45 以太网;服务器无串口)。用 **pymodbus**`ModbusTcpClient`,由它处理封帧 / CRC / 超时重试 / float 解码。
- 角色厘清:**我们的后端 = Modbus client/master****网关 = TCP server**(在 "Modbus TCP" 模式下终结 TCP 再转 RTU);**电表 = 网关后面的 RTU slave**,由 `unit_id` 寻址。`modbus_device` 一行同时装下网关地址(host:port)与 slave 地址(unit_id)。
- 网关若开"Modbus TCP"协议转换 → pymodbus 默认 framer 直连。
- 网关若是透传(RTU-over-TCP,裸 RTU 帧含 CRC)→ pymodbus 用 RTU framer over TCP。
- **二者实现时对一次即可确定**(连上读 Voltage 寄存器验证),不影响表结构与上层。
- **协议知识在 YAML profile,部署信息在 DB**(见 §3.0):
- `app/integrations/modbus/driver.py`:薄封装 pymodbus 的连接 + 块读 + 大端 float32 解码(word/byte 都大端,高寄存器在前)。
- `app/integrations/modbus/profiles.py`YAML profile 的**加载 + 校验 + 解码 + 实体枚举**——纯"数据 + 函数"**不做 OOP 抽象基类/继承**。`load_profile(name) -> ModbusProfile`(pydantic 模型,启动期校验)、`decode(profile, registers) -> dict[key -> value]``enumerate_entities(device, profile) -> list[ExposableEntity]`(供 §3.3)。
- `app/integrations/modbus/profiles/*.yaml`:每个设备型号一个 YAML。首个 = `sdm120.yaml`(寄存器地址见参考文档 §4)。
- profile 输出统一的 `dict[key -> value]`,由 service 整个塞进 `modbus_reading.payload`JSON);profile 没列出的量根本不读。
- **只读**:不写电表配置寄存器(改 Meter ID/波特率有通信中断风险)。
- **轮询**:每个设备一个 `poll_interval_s`(默认 5s),APScheduler 一个 job 扫所有 `enabled` 设备,逐设备块读→解码→落库。9600 总线上多设备串行,5s 余量充足。
- 全局开关 `MODBUS_POLLING_ENABLED`CONFIG_FIELDS)可一键停采。
- 采集成功/失败写入设备的 `last_poll_at` / `last_poll_ok`(供 §3.3 的 online binary_sensor)。
- **两级周期 / 降采样 / 保留是后续杠杆**(见 §10),本里程碑用单周期读全。
**`sdm120.yaml` 示例(只含固定协议知识)**
```yaml
name: sdm120
description: Eastron SDM120 single-phase energy meter
function_code: 4 # input registers (FC04)
word_order: big # 高寄存器在前
byte_order: big
blocks: # 块读,减少 Modbus 事务
- { start: 0x0000, count: 0x0060 } # 30001..30095 连续段
- { start: 0x0156, count: 0x0004 } # total active/reactive energy
metrics:
- { key: voltage, address: 0x0000, type: float32, unit: V, device_class: voltage, ha_component: sensor }
- { key: current, address: 0x0006, type: float32, unit: A, device_class: current, ha_component: sensor }
- { key: active_power, address: 0x000C, type: float32, unit: W, device_class: power, ha_component: sensor }
- { key: power_factor, address: 0x001E, type: float32, unit: "", device_class: power_factor, ha_component: sensor }
- { key: frequency, address: 0x0046, type: float32, unit: Hz, device_class: frequency, ha_component: sensor }
- { key: import_energy, address: 0x0048, type: float32, unit: kWh, device_class: energy, state_class: total_increasing, ha_component: sensor }
- { key: export_energy, address: 0x004A, type: float32, unit: kWh, device_class: energy, state_class: total_increasing, ha_component: sensor }
- { key: total_energy, address: 0x0156, type: float32, unit: kWh, device_class: energy, state_class: total_increasing, ha_component: sensor }
# 冷门量(demands / maxima / 无功电能…)可后续按需补进 metrics;未列出的就不读。
```
> 注意 YAML 里**没有** unit_id / friendly_name——那些是 `modbus_device` 行各自带的。
### 3.2 数据模型(新增两张通用表,单库 app 链)
**`modbus_device`**(设备定义 = 部署/可配置项,CRUD 管理)
| 列 | 类型 | 说明 |
| --- | --- | --- |
| id | int PK | 内部代理主键(FK join 用)|
| uuid | str unique | uuid4 生成的**稳定内部身份**;也作 API 路径键与 HA `unique_id` 的锚 |
| friendly_name | str | 显示名(可改;改名重发 discovery,HA 显示名跟着变)|
| transport | str | 现仅 `'tcp'` |
| host | str | 网关 IP |
| port | int | 网关端口(默认 502|
| unit_id | int | Modbus 从机地址 = 电表 Meter ID(设备面板可改,默认 1)|
| profile | str | 用哪个 YAML profile(首个 `'sdm120'`|
| poll_interval_s | int | 采样周期(默认 5|
| enabled | bool | 是否轮询 |
| last_poll_at | datetime null | 最近一次轮询时刻(online 判定)|
| last_poll_ok | bool null | 最近一次轮询成败(online 判定)|
| created_at / updated_at | datetime | |
**`modbus_reading`**(通用遥测表,一行 = 一个设备的一次采样)
| 列 | 类型 | 说明 |
| --- | --- | --- |
| id | int PK | |
| device_id | int FK→modbus_device.id | **ON DELETE RESTRICT**(见 §5 删除语义)|
| recorded_at | datetime (UTC) | 采样时刻,**真实列、带索引**(所有查询按它走时间窗)|
| payload | JSON | profile 解码出的全部工程量 `{key: value}`;无固定列 |
- 索引:`(device_id, recorded_at)`
- **SDM120 单相 payload 示例**`{"voltage": 230.2, "current": 1.3, "active_power": 295.0, "power_factor": 0.98, "frequency": 50.0, "import_energy": 123.4, "export_energy": 0.0, "total_energy": 123.4}`——key 由 `sdm120.yaml``metrics[].key` 决定。
- **接入新设备型号无需改表**:换 profilepayload 里的 key 集合随之变;表结构不动。
- **三相电表**以后用三相 profilepayload 里多几个相位 key(如 `voltage_l1/l2/l3`),仍是同一张表。
### 3.3 MQTT + HA Discovery(通用 expose 框架,元数据由 profile 派生)
HA MQTT Discovery 模型 = **device → entities**:往 `<prefix>/<component>/<node>/<object>/config` 发 retained 消息定义一个 entityconfig 内 `device.identifiers` 相同的 entity 归到同一个 HA device 卡片下;之后往 `state_topic` 推值。
- **可暴露实体目录由 provider 动态产出**
- `app/integrations/expose.py`:定义 `ExposableEntity``key`(稳定)、`component`sensor/binary_sensor/switch…)、`device`(归属,决定 HA device 分组)、`device_class``unit`、取值来源)+ 一个 provider 注册表。
- **Modbus/energy provider**:每个 `enabled` 设备 = 一个 HA **device**`identifiers` 用设备 `uuid`),其各工程量 = 一组 sensor entity——**`device_class`/`unit`/`component` 直接取自该设备 profile 的 `metrics[]`**(不再单独维护一份映射),外加一个 `binary_sensor`「online」(取 `last_poll_ok`)——这就是"**不止 sensor**"的体现。
- 其它 provider(如 public-ip、poo)可后续挂入,本里程碑只接 Modbus/energy provider。
- **HA 实体身份锚定(Z2M 模型)**discovery config 的 **`unique_id` 用设备 `uuid` + 量的 `key` 派生**(稳定,不随改名变);可见的 **`name` 用 friendly_name**。改 friendly_name → 重发 discovery → HA 显示名跟着变、但 `unique_id` 不变故历史不丢。topic 的 object_id 也用 uuid 派生(稳定、丑无所谓)。
- **`exposed_entities` 表**:只存"逐 key 的开关"`key` unique + `enabled` + `updated_at`)。目录本身由 provider 计算,表只记被勾选的状态(默认未勾 = 不暴露)。
- **MQTT 客户端**`app/integrations/mqtt.py`**paho-mqtt**`loop_start()` 后台线程;在 lifespan 起/停;支持配置变更后**重连 + 重发 discovery**。
- **发布时机**
- discovery configretained):连接成功时、目录/勾选变更时全量发;取消勾选时发空 payload 清除该 entity。
- state:采集在每次轮询后推最新值;另有一个周期 job 兜底重发所有 enabled 实体的 state + availability(在线)topic。
- **配置**(走现有扁平 CONFIG_FIELDS):`MQTT_ENABLED``MQTT_BROKER_HOST/PORT/USERNAME/PASSWORD(secret)``MQTT_TLS_ENABLED``HA_DISCOVERY_ENABLED``HA_DISCOVERY_PREFIX`(默认 `homeassistant`)。
### 3.4 前端
- **侧边栏**:把 `AppLayout` 从顶栏重构为侧边导航(Mantine `AppShell` 或 flex sidebar),导航项:Home / Records / Energy / Config + 主题切换 + 注销;当前路由高亮;移动端可折叠。仅改 `App.tsx`+ 可抽 `AppSidebar`/`NavItem` 组件),各页面主体不动。
- **Energy 视图**(新页 `/energy`,首个领域视图,消费通用 `/api/modbus` 数据):
- 设备管理:列表 + 新建/编辑/删除(删除有二次确认;后端对有读数的设备拒删,引导改用"禁用")。新建/编辑表单里设 friendly_name、host、port、unit_id、profile(下拉选 `sdm120` 等)、poll_interval、enabled。
- 最新读数卡片(每设备当前各工程量;字段标签/单位取自 profile 的 metrics 元数据,见 `/metrics` 端点)。
- 走势图:用 **Recharts** 画时间序列(电压/电流/功率/电能),时间范围选择,取数走 readings API(窗口 + 上限),从 `payload` 里按 key 取序列。
- **Expose 设置**:设置页内一块「Home Assistant Expose」——列出可暴露实体目录、逐项勾选、显示 MQTT/Discovery 连接状态、一个"重新发布 discovery"按钮。
## 4. API 契约(M5 要落地的端点)
> 全部 `/api` 前缀、session + CSRF(写)保护、JSON 进出。schema 经 `export_openapi.py` 固化入库。路径键用设备 `uuid`(稳定、非自增)。
| 分组 | 端点 | 用途 |
| --- | --- | --- |
| Modbus | `GET /api/modbus/devices` | 列出设备 |
| Modbus | `POST /api/modbus/devices` | 新建设备 |
| Modbus | `GET /api/modbus/devices/{uuid}` | 单个设备 |
| Modbus | `PATCH /api/modbus/devices/{uuid}` | 修改设备(含 enable/disable|
| Modbus | `DELETE /api/modbus/devices/{uuid}` | 删除设备;**有读数时 409**,引导改 disable |
| Modbus | `GET /api/modbus/devices/{uuid}/metrics` | 该设备 profile 的量目录(key/label/unit/device_class),供前端渲染卡片与图表标签 |
| Modbus | `GET /api/modbus/devices/{uuid}/latest` | 该设备最新一条读数(payload)|
| Modbus | `GET /api/modbus/devices/{uuid}/readings` | 时间范围读数(`start/end/limit`limit 有上限),返回 `recorded_at + payload`,供走势图 |
| Modbus | `POST /api/modbus/devices/{uuid}/test` | 即时试读一次(验证网关连通/地址),返回解码 payload,不落库 |
| Modbus | `GET /api/modbus/profiles` | 列出可用 profile 名 + 描述(前端建设备时的下拉选项)|
| Expose | `GET /api/expose` | 返回可暴露实体目录 + 勾选状态 + MQTT/Discovery 状态 |
| Expose | `PUT /api/expose` | 设置逐 key 勾选(map key→bool|
| Expose | `POST /api/expose/republish` | 手动重发 discovery |
| 配置 | `POST /api/config/mqtt/test` | 试连 broker **并发布一条测试消息**MQTT Explorer 可见),仿 SMTP 测试三态 |
> MQTT broker / discovery 的**标量配置**复用现有 `GET/PUT /api/config`(只新增 CONFIG_FIELDS,不新增端点)。
## 5. 已锁定决策(讨论后拍板)
1. **里程碑编排**:一个 M5 文档分三段,`Depends` 串顺序(A 侧边栏 → B Modbus/Energy → C MQTT)。
2. **两层数据模型,协议与部署分离**`modbus_device`(部署/可配置项:friendly_name、host、port、unit_id、profile 名、poll、enabled+ `modbus_reading`(通用遥测:device_id、recorded_at、`payload` JSON)。取代原"宽表 + 固定列"。
3. **读数 = JSON `payload`,无固定列**;聚合走 SQLite `json_extract`DB 端做 AVG/MAX/GROUP BY),热点量后补 generated column + 表达式索引。两级周期/降采样为后续杠杆。
4. **协议知识 = 仓库内只读 YAML profile**(声明式"数据 + 函数",无 OOP 继承,pydantic 启动期校验);YAML 携带寄存器 + 每个量的 key/unit/device_class/ha_component,是**唯一真相源**,同时驱动"读 / 存 / HA 注册"。多设备可共享一个 profile。首个 profile `sdm120`
5. **Modbus 仅 TCP**pymodbusframer 配网关模式,**只读**采集。设备是网关后面的 slave,`unit_id` 寻址(设备面板可改,故落 DB)。
6. **命名分层(方案 C**:存储/采集/API 一律通用 `modbus_*``/api/modbus/devices`;前端首个领域视图叫 **Energy**(消费通用 device 数据)。
7. **UUID = 内部生成(uuid4)的稳定身份**:既做内部索引/ API 路径键,也做 HA discovery `unique_id` 的锚;friendly_name 可改,改名重发 discovery、HA 显示名跟着变(Z2M 模型,历史不丢)。
8. **MQTT = 通用 expose 框架**:provider 动态产出可暴露实体目录,**实体元数据(device_class/unit/component)从 profile 派生**`exposed_entities` 只存逐 key 开关;支持 sensor/binary_sensor/switch 等多 component;设备自动注册(每设备一 device、各量为 entity + 一个 online binary_sensor)。
9. **MQTT 库 = paho-mqtt**,lifespan 长连接,配置变更后重连 + 重发 discovery。
10. **MQTT broker/discovery 标量配置走现有扁平 CONFIG_FIELDS**(自动渲染);设备清单与 expose 勾选走专用表 + 自定义 UI。
11. **图表库 = Recharts**(封在自包含组件后,仿 M2 对 Leaflet 的隔离)。
12. **删除设备安全**FK `ON DELETE RESTRICT`,有读数拒删(避免一键删表丢历史);"停用"用 `enabled=false`
13. **Config 页用 Accordion 分区**(进页见大类、逐类展开),**不在 config 页内再放第二个 side nav**——避免与主侧栏(T01)的"双抽屉"冲突;纯前端、独立任务 M5-T01B。
14. **CLI 手工测试工具为"受控手工链路验证"而设**(设备接市电、非随时在线,不进自动化):`scripts/modbus_cli` 提供 `read`profile 解码)与 `probe`(手工指定请求内容、看原始回复)两个子命令,**一律只读**(仅 FC03/04),不暴露写寄存器。
15. **MQTT/HA 发布链路的手工验证走 UI + 外部工具,不另做 CLI**Config 页「发送测试消息」(`mqtt/test` 发一条到测试 topic)→ 在 **MQTT Explorer** 查看;Expose 勾选实体 + 开 `HA_DISCOVERY_ENABLED` + 「重新发布 discovery」(`/api/expose/republish`)→ 到 **Home Assistant** 查看。不通则迭代配置再重发。
> 项目定位:个人自用、家庭特化、不开源——可按单用户场景简化,不过度抽象。
## 6. 任务依赖图
```
Phase A(独立,可最先做,纯前端)
M5-T01 [structural] 侧边栏布局重构
M5-T01B Config 页分区折叠(Accordion) ← 与 T01 互不依赖,可并行/先后任意
Phase BModbus 采集 + Energy 前端)
M5-T02 [schema] modbus_device + modbus_reading 表 + 模型
├─► M5-T03 Modbus 驱动 + YAML profile 框架(pymodbus + sdm120.yaml,纯模块)
│ └─► M5-T04 采集 service + APScheduler 轮询(接 lifespan,落 payload
└─► M5-T05 Modbus JSON APIdevice CRUD + readings + metrics + test
└─► M5-T06 前端:设备管理 UI(依赖 T01 侧栏 + T05 API
└─► M5-T07 前端:读数展示 + Recharts 走势图(依赖 T05;引入 recharts
Phase CMQTT / Discovery,依赖 B 的设备数据与 provider 接口)
M5-T08 MQTT/Discovery 配置项(CONFIG_FIELDS
M5-T09 [schema] exposed_entities 表 + ExposableEntity/provider 框架(modbus provider 从 profile 派生)
├─► M5-T10 MQTT 客户端(paholifespan 连接 + 重连 + config/mqtt/test
│ └─► M5-T11 Discovery 发布 + state 发布(连 T04 轮询推 state
└─► M5-T12 前端:Expose 勾选 UI + /api/expose API
收尾
M5-T13 文档 + OpenAPI + roadmap 收尾(依赖全部)
```
`T01``T01B``T02``T08` 无前置可先开。
---
## 7. 原子任务(任务卡)
> 后端任务沿用校验闸门(`pytest` / `ruff` / 改路由或 schema 则 `export_openapi` 重导出入库)。前端任务闸门见 §8。新增依赖(`pymodbus`、`PyYAML`、`paho-mqtt`、`recharts`)须在对应任务里同步 `requirements.in/.txt` 或 `frontend/package.json` 并重新锁定。
### M5-T01 — 侧边栏布局重构 `[structural]`
- **Status**: `todo` · **Depends**: none
- **Context**: 把 `AppLayout` 从顶栏改为侧边导航,给后续 Energy 等视图腾入口。纯前端,不碰各页主体。
- **Files**: `modify frontend/src/App.tsx``create frontend/src/components/AppSidebar.tsx``frontend/src/components/NavItem.tsx`(可选);`modify` 受影响的 `frontend/src/pages/*.test.tsx`(导航断言)
- **Steps**:
1. 用 Mantine `AppShell`(或 flex sidebar)重构 `AppLayout`:左侧竖直导航(Home/Records/Config + 主题切换 + 注销),`<Outlet/>` 在右。
2. 当前路由高亮(`useLocation` 比对 `pathname`);移动端可折叠(burger)。
3. 导航项图标沿用 `react-feather`;样式走 Mantine(暗色模式自动适配)。
4. 不在本任务加 Energy 项(页面还不存在,T06 加),保持导航无死链。
- **Out of scope / 不要碰**: 不改各页面主体;不动鉴权(SessionProvider/ProtectedRoute);不引入图表库。
- **Acceptance criteria**:
- [ ] 受保护页都在侧边栏布局内;`/login``/change-password` 不带布局(与现状一致)。
- [ ] 当前路由在侧栏高亮;移动端宽度下可折叠/展开。
- [ ] 前端闸门全绿(`lint`/`typecheck`/`test`/`build`)。
- **Reviewer checklist**: 布局只在 `App.tsx`/新组件内变动,未误改页面或鉴权;无死链导航项。
### M5-T01B — Config 页分区折叠(Accordion
- **Status**: `todo` · **Depends**: none
- **Context**: config 内容会越来越多(M5 还要加 MQTT / HA Discovery / Modbus 配置 + Expose 面板)。把 ConfigPage 从一长串 section 改为 Mantine `Accordion`:进页只见各大类标题,逐类展开编辑。**纯前端、单列内容**——它不是"第二个 app 级侧栏",与主侧栏(T01)无冲突,故 `Depends: none`、可独立先做。
- **Files**: `modify frontend/src/pages/ConfigPage.tsx``modify frontend/src/pages/ConfigPage.test.tsx`(折叠/展开断言)
- **Steps**:
1. 用 Mantine `Accordion` 包住现有"按 section 分组"的渲染:每个 config section = 一个 `Accordion.Item`(标题 = section 名,面板 = 该 section 的字段表单)。
2. 默认折叠(或首个展开);**保留现有保存逻辑与 config API 不变**M2 的整页/按 section 保存语义照旧,本任务不碰后端)。
3. 预留:将来 T12 的「Home Assistant Expose」自定义面板也作为一个 `Accordion.Item` 接入,保持一致。
4. 移动端单列堆叠即可,**不引入第二个侧栏/抽屉**。
- **Out of scope / 不要碰**: 不改后端 config API/字段(`CONFIG_FIELDS` / `_settings_payload`);不动主侧栏(T01);不改保存语义;不在 config 页内放任何 app 级 side nav。
- **Acceptance criteria**:
- [ ] ConfigPage 以 accordion 呈现,每大类可独立展开/折叠;字段渲染与保存行为与现状一致。
- [ ] 与主侧栏无视觉/交互冲突(单列内容;移动端不出现双抽屉)。
- [ ] 前端闸门全绿(`lint`/`typecheck`/`test`/`build`)。
- **Reviewer checklist**: 仅改 ConfigPage+其测试),未碰 config 后端或主 layout;保存逻辑无回归;页面内**无第二个 app 级 sidebar**accordion 是内容、非 chrome)。
### M5-T02 — `modbus_device` + `modbus_reading` 表与模型 `[schema]`
- **Status**: `todo` · **Depends**: none
- **Context**: 单库 app 链新增两张**通用**表,建出 §3.2 结构(设备 = 部署层,读数 = JSON payload 通用遥测层)。本任务只建 schema + 模型,不写采集/接口。
- **Files**: `create app/models/modbus.py``ModbusDevice``ModbusReading`,继承 `app.db.Base`);`create alembic_app/versions/<date>_07_modbus_tables.py``modify alembic_app/env.py`import 新模型);`modify scripts/app_db_adopt.py``APP_BASELINE_REVISION` → 新 head);`create tests/test_modbus_models.py`
- **Steps**:
1. 模型按 §3.2 列定义(`Mapped[...]` 2.0 风格):`ModbusDevice``uuid`(unique)、`friendly_name``transport``host``port``unit_id``profile``poll_interval_s``enabled``last_poll_at`/`last_poll_ok`(nullable)、时间戳;`uuid``default` 生成 uuid4 字符串。`ModbusReading``device_id` FK→`modbus_device.id`**`ondelete="RESTRICT"`**)、`recorded_at``payload`JSON 列,非 null)。
2. 新 revision`down_revision` = 当前 head`upgrade()``op.create_table` 建两表 + 索引 `(device_id, recorded_at)``downgrade()` 反向 drop。
3. 更新 `APP_BASELINE_REVISION`
- **Out of scope / 不要碰**: 不写 pymodbus/采集(T03/T04);不加路由(T05);不动其它模型。
- **Acceptance criteria**:
- [ ] 全新临时 app 库 upgrade 到 head 后含 `modbus_device``modbus_reading` 及索引;`downgrade -1` 干净回滚。
- [ ] `Base.metadata.tables` 含两新表;FK 为 RESTRICT`uuid` 唯一且自动生成。
- [ ] `APP_BASELINE_REVISION` == 新 head。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: 列/约束与 §3.2 一致;`payload` 为 JSON 列;`recorded_at` 为真实索引列(非塞进 payload);链上单 head`env.py` 已 import 新模型(否则 autogenerate/建表漏表)。
### M5-T03 — Modbus 驱动 + YAML profile 框架(`sdm120`
- **Status**: `todo` · **Depends**: M5-T02
- **Context**: 薄封装 pymodbus 的连接/块读/大端 float 解码,加 YAML profile 加载/校验/解码框架与 SDM120 profile。纯模块,mock client 单测。
- **Files**: `create app/integrations/modbus/__init__.py``app/integrations/modbus/driver.py``app/integrations/modbus/profiles.py``app/integrations/modbus/profiles/sdm120.yaml``scripts/modbus_cli.py``modify requirements.in`/`requirements.txt`(加 `pymodbus``PyYAML`,重新锁定);`create tests/test_modbus_driver.py``tests/test_modbus_profiles.py``tests/test_modbus_cli.py`
- **Steps**:
1. `driver.py``read_blocks(host, port, unit_id, blocks) -> dict[int,int]`(按 profile 的 blocks 块读 input registersFC04),用 `ModbusTcpClient`;超时/连接失败抛明确异常;大端 float32 解码 helper`registers_to_float`,高寄存器在前)。framer 选择留可配置/可探测。
2. `profiles.py`:定义 pydantic `ModbusProfile` / `MetricSpec``key`/`address`/`type`/`unit`/`device_class`/`state_class`?/`ha_component`);`load_profile(name) -> ModbusProfile`(读 `profiles/<name>.yaml`,校验失败抛错);`decode(profile, registers) -> dict[key -> value]`(按各 metric 的 address+type 从寄存器对解码);`list_profiles() -> list[(name, description)]`。**不做抽象基类/继承**,全是数据 + 模块函数。
3. `profiles/sdm120.yaml`:按 §3.1 示例写全核心量(参考文档 §4 地址)。
4. `scripts/modbus_cli.py`:纯命令行、**不依赖 DB / 不需先配设备**,供**受控手工测试**(设备接市电、非随时在线,不进自动化)。两个**只读**子命令:
- `read --host H --port P --unit U --profile sdm120`:按 profile 读一次、解码后**把各工程量打印成可读结果**(表格/JSON)——验证整套解码链路。
- `probe --host H --port P --unit U --fc 4 --address 0x0000 --count 2 [--decode float32]`:**手工指定要发送的请求内容**(功能码 FC03/04 + 起始地址 + 数量),打印**原始寄存器(hex)+ 可选大端 float 解码**——first-contact 验证网关连通、framer 模式与 unit 地址,可单读一个寄存器,不依赖 profile。
连接失败给清晰报错 + 非零退出。**只读**:CLI 仅暴露读功能码(FC03/04),**不提供任何写寄存器子命令**(数据红线 + 防改坏电表通信参数)。
- **Out of scope / 不要碰**: 不连真实硬件(单测用 mock/fake 返回已知寄存器字节);不写调度(T04);不写电表配置寄存器;不在 profile 里放 unit_id/friendly_name。
- **Acceptance criteria**:
- [ ] 单测:给定 `0x4366,0x3334` 解码为 `230.2`(参考文档实例);字序/字节序正确。
- [ ] 单测:`load_profile("sdm120")` 校验通过;`decode(profile, ...)` 把已知寄存器映射到正确 key 与值。
- [ ] 单测:profile YAML 缺字段/类型错时 `load_profile` 抛可识别校验错。
- [ ] 连接失败/超时抛可识别异常,不静默返回错值。
- [ ] `python -m scripts.modbus_cli read ...` 能(对 mock/真实网关)打印解码后的各工程量;连接失败非零退出。
- [ ] `python -m scripts.modbus_cli probe --fc 4 --address 0x0000 --count 2 ...` 能打印原始寄存器与可选解码值;CLI **无任何写寄存器子命令**
- [ ] 校验闸门全绿。
- **Reviewer checklist**: 解码确为大端、高寄存器在前;地址与参考文档一致;**CLI 与 driver 都无任何写寄存器路径(仅 FC03/04)**;profile 纯协议知识、无部署项;`requirements.txt` 已同步锁定 `pymodbus``PyYAML`
### M5-T04 — 采集 service + APScheduler 轮询
- **Status**: `todo` · **Depends**: M5-T03
- **Context**: 周期扫所有 enabled 设备,调用 driver 读+解码,落 `modbus_reading.payload`。仿 public-ip 的同步 job 模式。
- **Files**: `create app/services/modbus_poll.py``modify app/main.py`lifespan 注册 job);`create tests/test_modbus_poll.py`
- **Steps**:
1. `modbus_poll.py``poll_device(session, device) -> ModbusReading | None``load_profile` → driver 读 → `decode` → 把 dict 存进 `payload` 插一行;更新 `last_poll_at`/`last_poll_ok`);`poll_all_enabled_devices(session)` 遍历 enabled 设备;service 内吞异常并日志,不让 job 崩。
2. `main.py`:加同步 wrapper `_run_scheduled_modbus_poll`(自管 session),`add_job(IntervalTrigger(seconds=...), id="modbus-poll", max_instances=1, coalesce=True)`。周期取**最小 per-device interval 或一个基础 tick**(实现可用单一基础 tick + 各设备按自身 interval 取模决定本 tick 是否读,保持 job 简单);受全局 `MODBUS_POLLING_ENABLED` 控制。
3. 失败的设备记 `last_poll_ok=false`(供 T11 暴露),不影响其它设备。
- **Out of scope / 不要碰**: 不发 MQTTT11);不加 HTTP 路由(T05);不引入两级周期(后续杠杆)。
- **Acceptance criteria**:
- [ ] 单测:mock driver 返回已知 dict`poll_all_enabled_devices``modbus_reading` 精确 +N 行、`payload` 内容正确。
- [ ] 单测:某设备读失败时其它设备仍正常落库,job 不抛;失败设备 `last_poll_ok=false`
- [ ] `MODBUS_POLLING_ENABLED=false` 时不轮询。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: session 在 wrapper 内开关、try/finally 关闭;job `max_instances=1` 防叠加;无 N+1/每行单独 connect 的明显低效;异常不外泄崩 job;payload 为解码后的 dict(非裸寄存器)。
### M5-T05 — Modbus JSON APIdevice CRUD + readings + metrics + test
- **Status**: `todo` · **Depends**: M5-T02
- **Context**: 给前端提供设备 CRUD、最新读数、时间范围读数、量目录、即时试读。
- **Files**: `create app/api/routes/api/modbus.py``app/schemas/modbus.py``modify app/main.py`(注册路由);`create tests/test_api_modbus.py`
- **Steps**:
1. devices`GET`(list)/`POST`/`GET{uuid}`/`PATCH{uuid}`/`DELETE{uuid}`session+CSRF`POST` 校验 profile 名存在;`DELETE` 有读数 → 409。
2. readings`GET {uuid}/latest`(最新一行 payload)、`GET {uuid}/readings``start/end/limit`limit 有上限防全表导出,按 `recorded_at` 升序,返回 `recorded_at + payload`)。
3. `GET {uuid}/metrics`:返回该设备 profile 的量目录(key/label/unit/device_class),供前端渲染。
4. `GET /api/modbus/profiles``list_profiles()` 的名+描述。
5. `POST {uuid}/test`:用 driver 即时读一次返回解码 payload(或错误),**不落库**。
- **Out of scope / 不要碰**: 不在此处发 MQTT;不写采集逻辑(复用 T03/T04 的 driver/service)。
- **Acceptance criteria**:
- [ ] CRUD 行为正确:创建/改/删行数精确;删有读数的设备返回 409;未登录 401、缺 CSRF 403;建设备引用不存在 profile → 422。
- [ ] readings 时间范围 + limit 上限生效;latest 返回最新一条 payloadmetrics 返回 profile 量目录。
- [ ] schema 经 OpenAPI 固化入库。
- [ ] 校验闸门全绿(含 `openapi/` 重导出)。
- **Reviewer checklist**: 删除受 RESTRICT 保护、无批量删/清表路径;查询走 `(device_id, recorded_at)` 索引;test 端点确不落库;路径键用 `uuid`
### M5-T06 — 前端:设备管理 UIEnergy 视图)
- **Status**: `todo` · **Depends**: M5-T01, M5-T05
- **Context**: 在侧栏加 Energy 入口与 `/energy` 路由;设备增删改 + 试读。
- **Files**: `create frontend/src/pages/EnergyPage.tsx``frontend/src/energy/DeviceForm.tsx``frontend/src/energy/hooks.ts``modify frontend/src/App.tsx`(路由)、`frontend/src/components/AppSidebar.tsx`Energy 项);`create` 对应 `*.test.tsx`
- **Steps**: `useQuery`/`useMutation``/api/modbus/devices` API;列表 + 新建/编辑表单(friendly_name/host/port/unit_id/profile 下拉/poll/enabled+ 删除二次确认(删失败 409 提示改用禁用);"试读"按钮调 `POST {uuid}/test` 显示结果。
- **Out of scope / 不要碰**: 走势图在 T07;不碰其它页面。
- **Acceptance criteria**:
- [ ] 能增/改/删设备并即时刷新;删除有二次确认;409 有友好提示;profile 走 `/api/modbus/profiles` 下拉。
- [ ] 侧栏出现 Energy 入口、`/energy` 可达。
- [ ] 前端闸门全绿。
- **Reviewer checklist**: 全部走生成的类型化 client;删除走确认;无与契约不符的手写请求。
### M5-T07 — 前端:读数展示 + Recharts 走势图
- **Status**: `todo` · **Depends**: M5-T05(数据), M5-T06(页面壳)
- **Context**: 在 Energy 页展示每设备最新读数 + 时间序列走势。
- **Files**: `modify frontend/src/pages/EnergyPage.tsx``create frontend/src/energy/EnergyCharts.tsx`(封装 Recharts);`modify frontend/package.json`(加 `recharts``package-lock.json` 同步);`create` 对应测试
- **Steps**: 最新读数卡片(接 `latest`,字段标签/单位取自 `/metrics`);时间范围选择 + 折线图(电压/电流/功率/电能,从 `payload` 按 key 取序列),接 `readings`(窗口 + limit);图表封在 `EnergyCharts` 内(仿 Leaflet 隔离,便于将来换库)。
- **Out of scope / 不要碰**: 不做服务端降采样(后续);不改后端。
- **Acceptance criteria**:
- [ ] 最新读数与走势图渲染正确;时间范围只取窗口数据(不拉全量);标签/单位来自 metrics。
- [ ] Recharts 封装自包含、仅此处 import。
- [ ] 前端闸门全绿(`build` 通过,注意 chunk 体积提示)。
- **Reviewer checklist**: 图表组件隔离;查询有窗口/上限;空数据/加载/错误态有处理;从 payload 取 key 的逻辑容忍缺 key。
### M5-T08 — MQTT / Discovery 配置项(CONFIG_FIELDS
- **Status**: `todo` · **Depends**: none
- **Context**: 把 MQTT broker 与 discovery 的标量配置接入扁平配置系统(前端自动渲染)。
- **Files**: `modify app/config.py`(新增 Settings 字段)、`app/services/config_page.py`(追加 CONFIG_FIELDS + `_settings_payload`);`modify .env.example``modify tests/test_api_config.py`
- **Steps**: 加字段 `mqtt_enabled``mqtt_broker_host/port/username/password`(secret)、`mqtt_tls_enabled``ha_discovery_enabled``ha_discovery_prefix`(默认 `homeassistant`)、`modbus_polling_enabled`CONFIG_FIELDS 归入「MQTT」「Home Assistant Discovery」「Modbus」section`_settings_payload` 补齐对应行。
- **Out of scope / 不要碰**: 不建 MQTT 客户端(T10);不动 expose 表(T09)。
- **Acceptance criteria**:
- [ ] 新配置项在 `GET /api/config` 出现且分 sectionpassword 为 secret(回空、留空保留);port 为 number。
- [ ] 非法值(端口非数字)422 不写库。
- [ ] 校验闸门全绿(OpenAPI 若变化则重导出)。
- **Reviewer checklist**: secret 不回显/不入 OpenAPI 示例;`_settings_payload` 未漏字段(否则运行期 override 丢失)。
### M5-T09 — `exposed_entities` 表 + ExposableEntity/provider 框架 `[schema]`
- **Status**: `todo` · **Depends**: M5-T02
- **Context**: 建"可暴露实体目录"的抽象与开关存储;modbus provider 把设备映射成 device/entities**实体元数据从 profile 派生**。
- **Files**: `create app/integrations/expose.py``ExposableEntity`、provider 协议、注册表、modbus provider);`create app/models/expose.py``ExposedEntityToggle``key` unique + `enabled` + `updated_at`);`create alembic_app/versions/<date>_08_exposed_entities.py``modify alembic_app/env.py``scripts/app_db_adopt.py``create tests/test_expose_catalog.py`
- **Steps**:
1. `ExposableEntity``key/component/device/device_class/unit/value_getter`+ provider 接口 `enumerate(session) -> list[ExposableEntity]`
2. modbus provider:每个 enabled 设备 → 一个 device`identifiers` 用设备 `uuid`),其各量 → sensor entity**device_class/unit/component 取自 profile 的 `metrics[]`**),加一个 `binary_sensor` online(取 `last_poll_ok`)。
3. `build_catalog(session)` 合并所有 provider 的实体 + 各自 `enabled`(来自 toggle 表,缺省 false)。
4. migration 建 toggle 表;更新 baseline 常量。
- **Out of scope / 不要碰**: 不发 MQTTT11);不加 HTTPT12)。
- **Acceptance criteria**:
- [ ] 单测:建若干设备后 `build_catalog` 产出每设备对应 entity(含 online binary_sensor+ 正确 device 分组、device_class、unit(与 profile 一致)。
- [ ] toggle 表 migration 可升/降;缺省 enabled=false。
- [ ] 至少含一个非 sensor componentonline binary_sensor)。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: `key` 稳定(用设备 `uuid` + 量 key,不用自增 id,避免重建漂移);component 支持多类型;目录由 provider 计算、元数据源自 profile 而非写死。
### M5-T10 — MQTT 客户端(paholifespan 连接 + 重连)
- **Status**: `todo` · **Depends**: M5-T08
- **Context**: 长连接 MQTT 客户端,配置变更可重连;含连接测试端点。
- **Files**: `create app/integrations/mqtt.py``modify app/main.py`lifespan 起/停)、`app/api/routes/api/config.py``POST /api/config/mqtt/test`);`modify requirements.in`/`requirements.txt`(加 `paho-mqtt` 重新锁定);`create tests/test_mqtt_client.py`
- **Steps**:
1. `MqttManager``is_configured()``connect()/disconnect()/reconnect(settings)``publish(topic, payload, retain)`paho `loop_start()` 后台线程;未配置/未启用则 no-op。
2. lifespan:启用则 connectshutdown disconnect。
3. 配置保存后若 MQTT 设置变化 → 触发 manager 重连(在 config 保存路径加 hook 或保存后比对)。
4. `POST /api/config/mqtt/test`:用提交/现存配置试连**并发布一条测试消息**到一个测试 topic(如 `<discovery_prefix>/home-automation/test`),返回三态(success/config-error/failed)。仿 SMTP 测试"发一封测试邮件"的语义——用户随后在 **MQTT Explorer** 里就能看到这条消息,确认 broker 发布链路通(这是 MQTT 端的手工验证手段,**不另做 CLI**)。
- **Out of scope / 不要碰**: 不构建 discovery/state 消息(T11)。
- **Acceptance criteria**:
- [ ] 单测(fake broker/paho mock):configured 时 connect 调用正确;未配置 no-oppublish 透传 topic/payload/retain。
- [ ] `POST /api/config/mqtt/test` 试连**并发布一条测试消息**(可在 MQTT Explorer 看到);三态有明确返回;session+CSRF 保护。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: 断网/连接失败不崩主进程;线程在 shutdown 正确停止;`requirements.txt` 同步锁定 `paho-mqtt`;密码不进日志。
### M5-T11 — Discovery 发布 + state 发布
- **Status**: `todo` · **Depends**: M5-T09, M5-T10
- **Context**: 把 enabled 实体发成 HA discovery configretained)并周期推 state;采集轮询后推最新值。
- **Files**: `create app/services/ha_discovery.py``modify app/services/modbus_poll.py`(轮询后推 state)、`app/main.py`state 周期 job + 连接后/勾选变更后发 discovery)、`app/api/routes/api/...``/api/expose/republish` 在 T12 接,本任务提供 service);`create tests/test_ha_discovery.py`
- **Steps**:
1. `build_discovery_payload(entity)` → HA 规范 config`<prefix>/<component>/<node>/<object>/config`,含 `device` 块、`state_topic``device_class``unit_of_measurement``availability`**`unique_id` 用设备 uuid + 量 key 派生,`name` 用 friendly_name**)。
2. `publish_discovery(session)`:对 enabled 实体发 retained config;对取消勾选的发空 payload 清除。
3. `publish_states(session)`:取各实体当前值发 state;采集在 `poll_device` 成功后顺带推该设备实体 state + online。
4. lifespan:连接成功 / 目录或勾选变更后 `publish_discovery`;周期 job 兜底 `publish_states` + availability。
- **Out of scope / 不要碰**: 不做前端(T12);不改采集解码逻辑。
- **Acceptance criteria**:
- [ ] 单测:discovery payload 符合 HA 结构(device 分组正确、topic/device_class/unit 正确、`unique_id` 取自 uuid);取消勾选发空 payload。
- [ ] 单测:采集轮询成功后推对应 state topic;失败推 online=false。
- [ ] discovery 用 retained。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: 仅发 enabled 实体;entity `unique_id` 稳定(源自 uuid,不随改名变);MQTT 未启用时整链 no-op;不阻塞轮询。
### M5-T12 — 前端:Expose 勾选 UI + `/api/expose`
- **Status**: `todo` · **Depends**: M5-T09, M5-T11
- **Context**: 后端 expose 读写端点 + 设置页勾选界面。
- **Files**: `create app/api/routes/api/expose.py``app/schemas/expose.py``modify app/main.py``create tests/test_api_expose.py`;前端 `create frontend/src/pages/.../ExposeSettings.tsx`(或并入 ConfigPage)、`modify` 路由/设置入口;`create` 前端测试
- **Steps**: `GET /api/expose`(目录 + 勾选 + MQTT/Discovery 状态)、`PUT /api/expose`key→bool)、`POST /api/expose/republish`(调 T11 service);前端列出目录、按 device 分组、逐项开关、显示连接状态、"重新发布"按钮。
- **Out of scope / 不要碰**: 不改采集/发布逻辑(T11)。
- **Acceptance criteria**:
- [ ] `GET/PUT /api/expose` 正确读写勾选;session+CSRFOpenAPI 固化。
- [ ] 勾选变更后(或点重新发布)触发 discovery 重发。
- [ ] 前端能勾选并显示状态;前后端闸门全绿。
- **Reviewer checklist**: PUT 只改 toggle 不误碰其它配置;republish 真触发 T11;类型化 client。
### M5-T13 — 文档 + OpenAPI + roadmap 收尾
- **Status**: `todo` · **Depends**: 全部
- **Files**: `modify README.md`Modbus/Energy/MQTT 段、新依赖)、`docs/roadmap.md`M5 行 + 把"MQTT/IoT"从"下一阶段"毕业、新增 Modbus 采集方向)、`docs/architecture-overview.md`(新增 MQTT 通道与 Modbus 采集);`modify docs/design/README.md`(列入 m5);`run python scripts/export_openapi.py` 并提交 `openapi/`
- **Acceptance criteria**:
- [ ] 文档反映新链路;`git diff --exit-code openapi/` 无未提交差异。
- [ ] 校验闸门全绿。
- **Reviewer checklist**: 无残留旧描述(含旧 `energy_meters`/`/api/energy` 字样);OpenAPI 已入库。
---
## 8. 前端校验闸门(前端任务每次结束都要全绿)
`frontend/` 下:
```bash
npm ci
npm run lint
npm run typecheck
npm run test
npm run build # 必须产出 dist;留意 chunk 体积告警
```
- 后端若同任务改了路由/schema,仍需根目录 `python scripts/export_openapi.py` 并提交 `openapi/`
- 新增前端依赖(recharts)须提交 `package.json` + `package-lock.json`
## 9. 构建上下文完整性(M1 教训)
- 本里程碑**不删/移文件**,但新增了 Python 依赖(`pymodbus``PyYAML``paho-mqtt`)与前端依赖(`recharts`):必须同步 `requirements.in/.txt` 的重新锁定与 `package-lock.json`,否则镜像构建会缺包。
- 新增源文件都在 `app/``scripts/``frontend/` 既有 COPY 范围内,无需改 `Dockerfile``COPY`**但 `app/integrations/modbus/profiles/*.yaml` 是非 .py 资源**——确认 `COPY app ...` 把整个目录(含 YAML)带进镜像,且运行期能按相对路径定位 YAML(建议用 `importlib.resources` 或基于 `__file__` 的路径,别用 CWD 相对路径)。`scripts/modbus_cli.py` 须在镜像里可 `python -m scripts.modbus_cli` 调用;`tests/test_deployment.py::test_dockerfile_copy_sources_exist` 仍应通过。
- 发版前置走查(见 CLAUDE.md):真起 app 跑一次轮询、真连一次 broker、前端 Energy 视图人工瞄一眼渲染,再打 tag。
## 10. 后续杠杆(本里程碑不做,文档留痕)
- **热点量的 generated column + 索引**JSON `payload` 默认无法对单个量建索引;某个量若需高频聚合,给 `modbus_reading` 加一列 `GENERATED ALWAYS AS (json_extract(payload,'$.<key>'))` 并建索引——非破坏性、要哪个补哪个。
- **两级采样周期**:瞬时量(V/I/P/PF/Hz)快、累计电能慢——9600 总线吃紧或要把功率压到 5s 以下时再开(device 表加 `slow_interval_s`,读数表是通用 payload、无需改结构)。
- **保留 / 降采样**:5s 采样长期行数大(≈630 万行/年/设备),加定期降采样或保留窗口任务(独立于本里程碑),`GROUP BY` + `AVG(json_extract(...))` 在 SQL 端做。
- **更多 device profile**:三相电表(如 SDM630)新增一个 YAML profilepayload 里多几个相位 key),不改表、不改采集主链。
- **更多 expose provider**public-ip / poo 等挂入 expose 框架。
- **写 Modbus 寄存器**:当前只读;如需经 MQTT/UI 控制设备(switch 类),再单独评估安全边界。
## 11. 人工验收 walkthrough(实现完成后)
> 重点:用命令行**手工**读到设备数据并展示结果。这些是**受控手工测试**——设备接市电、并非随时在线,故**不纳入自动化测试**(自动化只用 mock);CLI 工具(`modbus_cli read/probe`)就是为这种"我想测的时候手动测一次"而设。可用 `docker compose` 起环境,命令在容器内或容器外跑均可。
**前提**:网关(Waveshare RTU↔TCP)已上电接入网络,电表 Meter ID 已知(默认 1)。
**1) 命令行直接试读(不依赖 DB,最快验证)**
- 容器内:`docker compose exec <app> python -m scripts.modbus_cli read --host <网关IP> --port 502 --unit 1 --profile sdm120`
- 或容器外:`source .venv/bin/activate && python -m scripts.modbus_cli read --host <网关IP> --port 502 --unit 1 --profile sdm120`
- 预期:打印解码后的工程量(电压/电流/有功功率/功率因数/频率/导入导出电能等),数值合理;连不上则清晰报错。
- **first-contact / 原始验证**`python -m scripts.modbus_cli probe --host <网关IP> --port 502 --unit 1 --fc 4 --address 0x0000 --count 2 --decode float32` —— 手工指定请求内容、看原始回复(电压寄存器应解出约 230V),用于确认网关 framer 模式与 unit 地址;建 profile / 设备前就能跑。
**2)(可选)经 API 试读已配置的设备**
- 先在前端 Energy 页或 `POST /api/modbus/devices` 建一个设备;
- `POST /api/modbus/devices/{uuid}/test` 即时试读,返回解码 payload(不落库)。
**3)(可选)验证后台轮询落库**
- 确认 `MODBUS_POLLING_ENABLED=true` 且设备 `enabled`;等一个采样周期;
- 看前端 Energy 视图的最新读数/走势图,或查 `GET /api/modbus/devices/{uuid}/readings` 有新行。
**4)(可选)手工验证 MQTT / HA Discovery 发布链路**(受控手工步骤,不进自动化)
- **broker 发布链路**:配好 MQTT broker 后,在 Config 页点「发送测试消息」(`POST /api/config/mqtt/test`)——它试连并发一条测试消息;打开 **MQTT Explorer** 确认能收到,即链路通。
- **HA Discovery**:在 Expose 设置勾选若干实体、开 `HA_DISCOVERY_ENABLED`,点「重新发布 discovery」(`POST /api/expose/republish`);到 **Home Assistant** 确认对应 device/entity 出现、值正确。
- **改名验证**:改某设备 friendly_name 后重发,确认 HA 显示名跟着变、历史不丢(`unique_id` 稳定)。
- 不通则按需调整配置/勾选再重发即可。
## 12. 里程碑完成定义(DoD)
- 后端能按 per-device 周期静默轮询 Modbus-TCP 设备、按 YAML profile 解码落 `modbus_reading.payload`,支持多设备 CRUD。
- MQTT 启用时,勾选的实体以 HA Discovery 注册成 device/entities(含非 sensor),state 周期发布;配置变更可重连重发;改 friendly_name 重发后 HA 显示名跟着变、`unique_id` 不变。
- 前端侧边栏可切换功能;Energy 视图能管理设备、看最新读数与走势图;设置页可勾选 expose。
- 后端 `pytest`/`ruff`/`export_openapi` + 前端 `lint/typecheck/test/build` 全绿且 `openapi/` 已入库。
- README / architecture / roadmap / design 索引反映 M5 现实。
+20
View File
@@ -0,0 +1,20 @@
# Future Ideas / Backlog(暂无 Milestone
记录尚未排期的想法。等某条成形、值得集中推进时,再升级为 `docs/roadmap.md` 里的 milestone 并展开成 `docs/design/` 任务卡。**这里只是备忘,不是承诺。**
> 项目定位:**个人自用、针对自家场景特化,不开源**。因此设计可按单用户 / 自家需求简化,不必为通用性、多租户、对外发布做过度抽象。
## 数据与存储
- 增加更多数据类型 / 来源(持续扩展)。
- 针对**需要长期保存**的数据,考虑更合适的存储方案(当前全 SQLite;长期 / 大量数据可能需要更强的数据库)。
- 把 **Home Assistant 接收到的数据**纳入本系统做持久化 / 展示。
## 集成
- **MQTT**:让后端作为一个 MQTT client,双向收发数据。
## 前端 / 移动端
- **PWA**(**近期、可能并入 M2 或单独小里程碑**):在 React NativeM3)之前,用 PWA 把 web SPA 包装成"准手机 App"——可安装到桌面、响应式、离线壳。
- 影响当下设计:**M2 的 UI 从一开始就按移动端布局考虑**(响应式 + 合理的参数显示),为之后加 PWA 铺路。
## 备注
- 以上为临时记录(讨论 M2 范围时随手想到),后续可增删、重排优先级。
Binary file not shown.
+134
View File
@@ -0,0 +1,134 @@
# SDM120 Modbus 协议(从官方 PDF 提取)
> 来源:`docs/references/SDM120-MODBUS_Protocol.pdf`
> Eastron SDM120 Modbus Smart Meter Modbus Protocol Implementation **V2.4**
> 本文件是 PDF 的可读化提取,供本项目的 Modbus 采集驱动设计参考。**以官方 PDF 为准**,本文件如有出入以 PDF 为准。
## 0. 本项目的接入方式(重要)
SDM120 物理层是 **Modbus RTURS-485 串口)**。本项目通过一个 **Modbus-TCP 网关**接入:
- 后端用 **Modbus TCP**`IP:port`)连到网关,网关在串口侧转成 RTU 与电表通信。
- TCP 帧用 MBAP header、**无 CRC**CRC/Error Check 由网关在 RTU 侧处理)。本文档里 RTU 帧的 `Error Check (Lo/Hi)` 字段在 TCP 模式下不需要我们关心。
- **Slave Address / Unit ID = 电表的 Meter ID**(默认 `1`,范围 1–247),在 TCP 请求里作为 unit id 传入。
- 若以后直连串口(RTU),才需要管波特率 / 校验位 / CRC(见 §5 holding 寄存器)。
## 1. 协议帧格式
MODBUS 定义 master 查询 / slave 响应的格式。Eastron 电表用 16-bit 寄存器在主从间传值,**但实际数据是 32-bit IEEE-754 浮点**,因此每个测量参数占**两个相邻的 16-bit 寄存器**。
### Querymaster → slaveRTU 帧)
| 字段 | 说明 |
| --- | --- |
| Slave Address | 8-bit,目标从机地址 12470=广播,Eastron 不支持广播) |
| Function Code | 8-bit,功能码(Eastron 支持 **03 / 04 / 08 / 16(=0x10)** |
| Start Address (Hi/Lo) | 16-bit 起始寄存器地址;**寄存器成对使用、从 0 开始,所以起始地址必须是偶数** |
| Number of Points (Hi/Lo) | 16-bit 请求的寄存器数量;**也必须是偶数**(成对读浮点) |
| Error Check (Lo/Hi) | 16-bit CRCRTU 模式;TCP 网关模式无此字段) |
### Responseslave → master
| 字段 | 说明 |
| --- | --- |
| Slave Address | 响应从机地址 |
| Function Code | 与查询相同的功能码(表示识别并已响应) |
| Byte Count | 8-bit,本次返回的数据字节数 |
| Data (寄存器对) | 每个寄存器 Hi byte / Lo byte,按"高寄存器在前"排列 |
| Error Check (Lo/Hi) | 16-bit CRCRTU 模式) |
### Exception Response(异常响应)
- 异常响应的 Function Code = 查询功能码 **OR 0x80**(最高位置 1)。
- 数据是单字节 Error Code(异常码)。
- PDF 正文写了"见后文 Table Of Exception Codes",但提取的 8 页里**没有附上该表**。标准 Modbus 异常码(供参考,非本 PDF 内容):`01` 非法功能、`02` 非法数据地址、`03` 非法数据值、`04` 从机设备故障。
## 2. 功能码
| 功能码 | 作用 | 寄存器区 |
| --- | --- | --- |
| **04** | Read Input Registers(读输入寄存器,3X)—— **所有测量值都在这里** | 30001+ |
| **03** | Read Holding Registers(读保持寄存器,4X)—— 配置项 | 40001+ |
| **16 / 0x10** | Write Holding Registers(写保持寄存器,4X)—— 改配置 | 40001+ |
| 08 | Diagnostics(诊断) | — |
> ⚠️ **测量值用 FC 04(输入寄存器),不是 FC 03。** 这是最常见的踩坑点。
## 3. 浮点数据编码
- 每个参数 = **32-bit IEEE-754 float**,占两个相邻 16-bit 寄存器。
- **字序(word order= 大端:高寄存器在前。**
- **字节序(byte order)= 大端:寄存器内高字节在前。**
- 即整体就是标准大端 float`>f`),4 字节顺序 = `[Reg1 Hi][Reg1 Lo][Reg2 Hi][Reg2 Lo]`
**实例(来自 PDF**
| 含义 | 原始 4 字节 (hex) | 解码值 |
| --- | --- | --- |
| Volts 1 | `43 66 33 34` | `230.2` V |
| Demand Time | `3F 80 00 00` | `1.0` |
| Network Node | `42 70 00 00` | `60.0` |
> Python 解码:`struct.unpack('>f', bytes([0x43,0x66,0x33,0x34]))[0]``230.2`
> pymodbus 用 `BinaryPayloadDecoder.fromRegisters(regs, byteorder=Endian.BIG, wordorder=Endian.BIG)`
## 4. 输入寄存器表(FC 04 读测量值)
全部为 `Float`,长度 4 字节,每项占 2 个寄存器。"Hex 起始"是 Modbus 协议起始地址(即 Start Address Hi/Lo)。
| 寄存器 | 参数 | 单位 | Hex 起始 |
| --- | --- | --- | --- |
| 30001 | Voltage(电压) | Volts | `0000` |
| 30007 | Current(电流) | Amps | `0006` |
| 30013 | Active power(有功功率) | Watts | `000C` |
| 30019 | Apparent power(视在功率) | VA | `0012` |
| 30025 | Reactive power(无功功率) | VAr | `0018` |
| 30031 | Power factor(功率因数) | — | `001E` |
| 30071 | Frequency(频率) | Hz | `0046` |
| 30073 | Import active energy(导入有功电能) | kWh | `0048` |
| 30075 | Export active energy(导出有功电能) | kWh | `004A` |
| 30077 | Import reactive energy(导入无功电能) | kvarh | `004C` |
| 30079 | Export reactive energy(导出无功电能) | kvarh | `004E` |
| 30085 | Total system power demand | W | `0054` |
| 30087 | Maximum total system power demand | W | `0056` |
| 30089 | Import system power demand | W | `0058` |
| 30091 | Maximum import system power demand | W | `005A` |
| 30093 | Export system power demand | W | `005C` |
| 30095 | Maximum export system power demand | W | `005E` |
| 30259 | Current demand | Amps | `0102` |
| 30265 | Maximum current demand | Amps | `0108` |
| 30343 | Total active energy(总有功电能) | kWh | `0156` |
| 30345 | Total reactive energy(总无功电能) | kvarh | `0158` |
**读取分块建议**`0x00000x005E`(30001–30095)地址连续,可一次块读;`0x0102/0x0108``0x0156/0x0158` 各为独立小块。整表用 2–3 次块读即可覆盖,减少 Modbus 事务数。
### 常用核心子集(日常监控够用)
电压 `0000`、电流 `0006`、有功功率 `000C`、功率因数 `001E`、频率 `0046`、导入有功电能 `0048`、导出有功电能 `004A`、总有功电能 `0156`
## 5. 保持寄存器表(FC 03 读 / FC 16 写配置)
| 寄存器 | 参数 | Hex 起始 | 格式 | 说明 |
| --- | --- | --- | --- | --- |
| 40013 | Relay Pulse Width | `000C` | Float | 继电器脉宽 60/100/200 ms,默认 100ms |
| 40019 | Network Parity Stop | `0012` | Float | 0=1停止位无校验(默认),1=1停止位偶校验,2=1停止位奇校验,3=2停止位无校验;**改后需重启生效** |
| 40021 | Meter ID | `0014` | Float | 从机地址 1247,默认 1 |
| 40029 | Baud rate | `001C` | Float | 0=2400(默认)1=48002=96005=1200 |
| 40087 | Pulse 1 output mode | `0056` | Float | 0001 导入有功,0002 导入+导出有功,0004 导出有功(默认),0005 导入无功,0006 导入+导出无功,0008 导出无功 |
| 463745 | Time of scroll display | `F900` | HEX 2字节 | 滚动显示时间 0–30s,默认 0(不滚动) |
| 463761 | Pulse 1 output | `F910` | HEX 2字节 | 0000:0.001kWh/imp(默认)0001:0.010002:0.10003:1 kWh/imp |
| 463777 | Measurement mode | `F920` | HEX 2字节 | 1:total=import2:total=import+export(默认)3:total=import-export |
| 464513 | Serial number | `FC00` | uint32 4字节 | 序列号,**只读** |
| 464515 | Meter code | `FC02` | Hex 2字节 | 设备码=`0020`**只读** |
| 464516 | Software version | `FC03` | Hex 2字节 | 软件版本,**只读** |
> ⚠️ 写保持寄存器(改 Meter ID / 波特率 / 校验位等)会改变电表通信参数,配错可能导致**通信中断**。本项目默认**只读采集**,不建议在自动化链路里写电表配置。
## 6. 给本项目采集驱动的要点小结
1. 走 **Modbus TCP 网关**`ModbusTcpClient(host, port)``slave=<Meter ID>`
2. 测量值用 **FC 04 / 输入寄存器**,按 §4 地址读。
3. 解码 **大端 float32**word & byte 都大端,高寄存器在前)。
4. 起始地址与数量**都用偶数**(成对读)。
5. 默认**只读**,不写电表配置寄存器。
6. 不同型号电表 → 不同"寄存器 profile"。本表是 `sdm120` 这一个 profile 的定义。
+51 -5
View File
@@ -2,7 +2,7 @@
本文档记录 `home-automation``v1.0.3` 之后的下一阶段规划。这一阶段不是小修补,而是几次较大的结构性改动:单库化、前端重写、以及远期的移动端试水。 本文档记录 `home-automation``v1.0.3` 之后的下一阶段规划。这一阶段不是小修补,而是几次较大的结构性改动:单库化、前端重写、以及远期的移动端试水。
> 每个里程碑的**可执行原子任务**展开在 [`docs/design/`](./design/README.md)M1 [`m1-db-consolidation.md`](./design/m1-db-consolidation.md)、M2 [`m2-frontend-v2.md`](./design/m2-frontend-v2.md)、M3 [`m3-token-mobile.md`](./design/m3-token-mobile.md)。这些文档为 Orchestrator→Implementer→Reviewer 的多模型流水线设计。 > 每个里程碑的**可执行原子任务**展开在 [`docs/design/`](./design/README.md)M1 [`m1-db-consolidation.md`](./design/m1-db-consolidation.md)、M2 [`m2-frontend-v2.md`](./design/m2-frontend-v2.md)、M3 [`m3-token-mobile.md`](./design/m3-token-mobile.md)、M4 [`m4-login-hardening.md`](./design/m4-login-hardening.md)。这些文档为 Orchestrator→Implementer→Reviewer 的多模型流水线设计。
## 当前基线(v1.0.3 ## 当前基线(v1.0.3
@@ -35,10 +35,12 @@
| 里程碑 | 主题 | 一句话 | | 里程碑 | 主题 | 一句话 |
| --- | --- | --- | | --- | --- | --- |
| **M1** ✅ | 单库化地基 | 把三库合并成单一 `app.db`,清理散落数据层,删掉 Grafana | | **M1** ✅ | 单库化地基 | 把三库合并成单一 `app.db`,清理散落数据层,删掉 Grafana |
| **M2** | 前端 v2 | React SPA 取代 Jinja,承载 config + 可视化 + 记录增删改 | | **M2** | 前端 v2 | React SPA 取代 Jinja,承载 config + 可视化 + 记录增删改 |
| **M4** ✅ | 登录加固 | 防爆破/指数退避 + CLI 逃生通道 + 可选 TOTP 二次验证(**先于 M5** |
| **M5** | IoT / 能耗采集 | Modbus/Energy + MQTT/HA Discovery + 前端侧边栏 |
| **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 | | **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 |
排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上,否则就是在准备推倒的旧数据层上盖新楼、之后回头返工 排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上;M4 是公网安全加固,在 M5 IoT 集成之前先堵住裸密码这个洞;M5 在安全基座就绪后再做 IoT 接入
--- ---
@@ -101,7 +103,7 @@
--- ---
## M2 — 前端 v2React SPA ## M2 — 前端 v2React SPA✅ 已完成
### 目标 ### 目标
@@ -125,9 +127,27 @@
### 鉴权边界(与 M3 衔接) ### 鉴权边界(与 M3 衔接)
- 现在那个裸 API 记小狗日志”的 ingestion 端点(设备 / 脚本调用,非浏览器)**维持现状到 M3**。 - 现在那个裸 API 记小狗日志”的 ingestion 端点(设备 / 脚本调用,非浏览器)**维持现状到 M3**。
- M2 新增的、浏览器调用的 CRUD 端点,用 session 保护即可,本步不引入 token。 - M2 新增的、浏览器调用的 CRUD 端点,用 session 保护即可,本步不引入 token。
> **M2 已完成**M2-T01 至 M2-T13 全部 done)。Jinja 模板已移除,React SPA 同源托管,多阶段 Docker 构建通过,所有校验闸门绿。
---
## M4 — 登录加固(✅ 已完成,排在 M5 之前)
### 目标
给暴露在公网的单 admin 登录做纵深防御,先于 M5 IoT 集成关闭暴力枚举和单因子风险。
### 范围
- **防爆破 / 指数退避**:失败登录按双键(IP + username)指数增长延迟,成功即清零。退避是延迟(429 + Retry-After),不是永久封号。全局开关 `AUTH_LOGIN_THROTTLE_ENABLED`CONFIG_FIELDS);反代后需开 `AUTH_TRUST_FORWARDED_FOR``.env` 部署级)。
- **CLI 逃生通道**`python -m scripts.admin_cli` 直连本地 DB,无需 HTTP 服务、无需任何已存凭据;支持重置密码、解锁退避、关停/重发 TOTP、查看用户。
- **可选 TOTP 二次验证**:admin 可自选启用;启用后两步登录(密码 + 6 位动态码或一次性恢复码);不启用维持纯密码。CLI `disable-totp` 是连恢复码都丢了时的最终逃生口。
> 详细设计与任务卡:[`docs/design/m4-login-hardening.md`](./design/m4-login-hardening.md)
--- ---
## M3 — 开放与移动端(远期试水) ## M3 — 开放与移动端(远期试水)
@@ -146,3 +166,29 @@
- 移动端是这一阶段最远期、最不确定的部分。 - 移动端是这一阶段最远期、最不确定的部分。
- token 主要是移动端的前置条件;Web 端 React 用现有 session cookie 即可,不需要为它提前引入 token。 - token 主要是移动端的前置条件;Web 端 React 用现有 session cookie 即可,不需要为它提前引入 token。
## 下一阶段:已确定要做(尚未拆解为任务卡)
> 这些是 M4 之后**已经定下来要做**的方向——区别于下面的 Future Ideas(仅备忘、未必做)。这里只记到 roadmap 粒度:确定**做什么、为什么**;具体排期、依赖与原子任务,等动手时再展开成 `docs/design/` 的任务卡。**先后顺序未定**,具体排期等动手时再定。
### 1. 前端优化
**动机**M2 的 React SPA 先把功能跑通,性能 / 体验层面的打磨还没做。这一项**确定要做,但具体优化什么还没定**。
**范围(待定)**:方向先留空,想清楚再细化。可能的候选(仅占位、非承诺):打包体积与代码分割(M2 构建已提示存在 > 500 kB 的单 chunk)、首屏加载、热力图 / 地图的渲染性能、移动端适配、可访问性等。等确定具体目标后再拆任务卡。
### 2. 设置页生成 Long-lived Token(供 API 调用)
**动机**:浏览器端走 session cookie 即可,但**脚本 / 设备 / 外部程序调用 API** 需要一种长期有效、可随身携带的凭据。在设置页加一组功能,由 admin **手动签发 long-lived token**,之后用它来调 API。
**范围(粗略,待细化)**
- 设置页新增「API Token」区:生成 / 命名 / 吊销 long-lived token;明文只在**生成时展示一次**,此后只存哈希。
- 后端支持用该 token 鉴权访问 API(与现有 session cookie 并存,互不影响)。
- 与 [M3](#m3--开放与移动端远期试水) 的 token 主题相关,但**这条是 Web 设置页手动签发的 PAT 风格**,不依赖移动端 OAuth 流程;两者实现时可复用同一套 token 存储 / 校验。
## Future Ideas(暂不排期,想到先记下)
> 这里收集**还没排进里程碑、也还没决定要不要做**的想法。不是承诺、也没有先后顺序;想做时再从这里捞出来——先升进上面的「下一阶段」,再细化成 `docs/design/` 的任务卡。
_(暂无条目。)_
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
dist-ssr/
*.local
.env
.env.*
!.env.example
+209
View File
@@ -0,0 +1,209 @@
# Home Automation — Frontend
React SPA for the home-automation backend. Built with Vite + React 18 + TypeScript.
Scaffolded in M2-T06; feature pages filled in by T07T10.
## Stack
| Layer | Library | Version |
|---|---|---|
| Build | Vite | 6.x |
| UI framework | React | 18.x |
| Language | TypeScript | 5.x |
| Component library | Mantine | 7.x |
| Data fetching | TanStack Query | 5.x |
| Routing | react-router-dom | 6.x |
| API client codegen | openapi-typescript | 7.x |
| API client runtime | openapi-fetch | 0.17.x |
| Testing | Vitest + @testing-library/react | 4.x / 14.x |
## npm Scripts
| Command | What it does |
|---|---|
| `npm run dev` | Start Vite dev server (with backend proxy — see below) |
| `npm run build` | `tsc -b && vite build` — type-check then build to `dist/` |
| `npm run preview` | Serve the built `dist/` locally |
| `npm run lint` | ESLint (flat config, React + TypeScript rules) |
| `npm run typecheck` | `tsc --noEmit` — type-check without emitting files |
| `npm run test` | Vitest (run once, no watch) |
| `npm run codegen` | Regenerate `src/api/schema.d.ts` from `../openapi/openapi.json` |
All frontend gates must pass before any task is considered done:
```bash
npm run codegen
npm run lint
npm run typecheck
npm run test
npm run build # must produce dist/
```
## Directory Structure
```
frontend/
├── index.html Vite entry HTML
├── vite.config.ts Vite + Vitest config; dev proxy
├── tsconfig.json References tsconfig.app.json + tsconfig.node.json
├── tsconfig.app.json App source TS config (strict, react-jsx)
├── tsconfig.node.json Vite config TS config
├── eslint.config.js Flat ESLint config (React + TypeScript rules)
├── package.json Dependencies + npm scripts
├── package-lock.json Lockfile (committed; CI uses npm ci)
└── src/
├── main.tsx Entry point; mounts <App> into #root
├── App.tsx Provider stack + route tree (MantineProvider → QueryClient → Router → SessionProvider)
├── vite-env.d.ts /// <reference types="vite/client" /> for CSS imports
├── test-setup.ts Vitest global setup (@testing-library/jest-dom)
├── api/
│ ├── schema.d.ts AUTO-GENERATED from openapi/openapi.json (committed)
│ ├── client.ts openapi-fetch client + CSRF/cookie/401 middleware
│ └── csrf.ts Module-level CSRF token holder (setCsrfToken / getCsrfToken)
├── auth/
│ ├── SessionProvider.tsx TanStack Query against GET /api/session; exposes useSession()
│ └── ProtectedRoute.tsx Redirects to /login when unauthenticated
└── pages/
├── LoginPage.tsx Placeholder → T07 builds the real form
├── HomePage.tsx Placeholder → T09 builds the map/heatmap view
└── ConfigPage.tsx Placeholder → T08 builds the config editor
```
## Dev Proxy (local development)
`npm run dev` starts Vite on port 5173. The Vite config proxies API/auth paths
to the FastAPI backend running on port 8000:
| Proxied path | Backend URL |
|---|---|
| `/api/*` | `http://localhost:8000` |
| `/login` | `http://localhost:8000` |
| `/logout` | `http://localhost:8000` |
| `/static/*` | `http://localhost:8000` |
| `/docs` | `http://localhost:8000` |
| `/openapi.json` | `http://localhost:8000` |
To develop locally:
1. Start the backend: `uvicorn app.main:app --reload --host 0.0.0.0 --port 8000`
2. Start the frontend: `cd frontend && npm run dev`
3. Open `http://localhost:5173` — the app proxies all API calls to the backend.
Since the dev server proxies the session cookie path, auth flows work exactly as
they would in the deployed (same-origin) setup.
## Adding a New Page + Typed Query
This is the pattern every task T07T10 follows to wire up a real page:
### 1. Run codegen (if the OpenAPI contract changed)
```bash
npm run codegen
```
The generated `src/api/schema.d.ts` is committed to the repo. CI enforces that
the file is in sync with `openapi/openapi.json` via:
```bash
npm run codegen && git diff --exit-code frontend/src/api/schema.d.ts
```
### 2. Import the typed client
```typescript
// src/pages/SomePage.tsx
import apiClient from '../api/client'
```
### 3. Write a typed TanStack Query
```typescript
import { useQuery } from '@tanstack/react-query'
import apiClient from '../api/client'
function usePooRecords(limit = 100) {
return useQuery({
queryKey: ['poo', { limit }],
queryFn: async () => {
const res = await apiClient.GET('/api/poo', { params: { query: { limit } } })
// res.data is typed as PooResponse | undefined
// On non-2xx the middleware throws ApiError; TanStack Query catches it.
return res.data
},
})
}
```
The `params.query` and `params.path` objects are fully typed from `schema.d.ts`.
TypeScript will error if you pass unknown query params or mistype a path param.
### 4. Write a typed mutation (write request)
```typescript
import { useMutation, useQueryClient } from '@tanstack/react-query'
import apiClient from '../api/client'
function useDeletePoo() {
const qc = useQueryClient()
return useMutation({
mutationFn: (timestamp: string) =>
apiClient.DELETE('/api/poo/{timestamp}', {
params: { path: { timestamp } },
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['poo'] }),
})
}
```
The middleware (`src/api/client.ts`) automatically injects the `X-CSRF-Token` header
on all non-GET/HEAD requests (sourced from `getCsrfToken()`). You do not need to
handle CSRF manually in page code.
### 5. Add the route in App.tsx
```typescript
// App.tsx
import { SomePage } from './pages/SomePage'
// Inside <Routes>:
<Route path="/some-path" element={<SomePage />} />
// or, if protected:
<Route
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
>
<Route path="/some-path" element={<SomePage />} />
</Route>
```
## OpenAPI codegen + CI sync rule
`src/api/schema.d.ts` is committed to the repository (not gitignored).
**Rule**: whenever `openapi/openapi.json` changes (any backend task that modifies
a route or schema), CI must run:
```bash
cd frontend && npm run codegen
git diff --exit-code frontend/src/api/schema.d.ts
```
If the file has changed but the new version was not committed, CI fails.
To update manually after a backend change:
```bash
cd frontend
npm run codegen
git add src/api/schema.d.ts
git commit -m "M2-Txx: update generated OpenAPI types"
```
## Production Build
The production build (`npm run build`) writes static files to `frontend/dist/`.
In the deployed setup (M2-T11 onwards), FastAPI serves `dist/` as a static
directory and falls back to `dist/index.html` for all non-`/api` paths,
enabling client-side routing with deep links.
The multi-stage Dockerfile (M2-T12) builds the frontend in a Node container and
copies only `dist/` into the Python image — the production image does not
contain Node or npm.
+29
View File
@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactPlugin from 'eslint-plugin-react'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist', 'src/api/schema.d.ts'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
react: reactPlugin,
},
rules: {
...reactHooks.configs.recommended.rules,
'react/react-in-jsx-scope': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
},
},
)
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Home Automation</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+7299
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
{
"name": "home-automation-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"codegen": "openapi-typescript ../openapi/openapi.json -o ./src/api/schema.d.ts"
},
"dependencies": {
"@mantine/core": "^7.17.8",
"@mantine/hooks": "^7.17.8",
"@tanstack/react-query": "^5.101.0",
"@types/leaflet": "^1.9.21",
"@types/leaflet.markercluster": "^1.5.6",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"leaflet.markercluster": "^1.5.3",
"openapi-fetch": "^0.17.0",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-feather": "^2.0.10",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.30.4"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^14.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.31",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.7.0",
"eslint": "^9.39.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"jsdom": "^29.1.1",
"openapi-typescript": "^7.13.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.61.0",
"vite": "^6.4.3",
"vitest": "^4.1.8"
}
}
+219
View File
@@ -0,0 +1,219 @@
/**
* App top-level provider stack and route tree.
*
* Provider order (outermost first):
* MantineProvider QueryClientProvider BrowserRouter SessionProvider routes
*
* Route tree:
* /login LoginPage (public)
* /change-password ProtectedRoute ChangePasswordPage (T07: forced password change gate)
* / ProtectedRoute AppLayout HomePage (T09)
* /config ProtectedRoute AppLayout ConfigPage (T08)
*
* AppLayout renders a nav with a gear-icon entry for /config and a logout button (T07).
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Routes, Route, Link, Outlet, useNavigate } from 'react-router-dom'
import {
MantineProvider,
Group,
ActionIcon,
Tooltip,
useMantineColorScheme,
useComputedColorScheme,
} from '@mantine/core'
import { List, Settings, Sun, Moon, LogOut } from 'react-feather'
// Mantine requires its CSS to be imported once.
import '@mantine/core/styles.css'
import { SessionProvider } from './auth/SessionProvider'
import { ProtectedRoute } from './auth/ProtectedRoute'
import { LoginPage } from './pages/LoginPage'
import { HomePage } from './pages/HomePage'
import { ConfigPage } from './pages/ConfigPage'
import { RecordsPage } from './pages/RecordsPage'
import { ChangePasswordPage } from './pages/ChangePasswordPage'
import apiClient from './api/client'
import { useQueryClient } from '@tanstack/react-query'
// ---------------------------------------------------------------------------
// TanStack Query client (singleton, created outside render to avoid re-creation)
// ---------------------------------------------------------------------------
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Don't retry on 4xx — we handle 401 in the middleware
retry: (failureCount, error) => {
if (error instanceof Error && 'status' in error) {
const status = (error as unknown as { status: number }).status
if (status >= 400 && status < 500) return false
}
return failureCount < 2
},
},
},
})
// ---------------------------------------------------------------------------
// Logout button component (needs navigate + queryClient hooks, so it's a component)
// ---------------------------------------------------------------------------
function LogoutButton() {
const navigate = useNavigate()
const qc = useQueryClient()
async function handleLogout() {
try {
await apiClient.POST('/api/auth/logout')
} catch {
// Ignore errors on logout — we clear the session regardless.
}
// Invalidate session so SessionProvider becomes unauthenticated.
await qc.invalidateQueries({ queryKey: ['session'] })
navigate('/login', { replace: true })
}
return (
<Tooltip label="Log out">
<ActionIcon
variant="default"
size="lg"
onClick={handleLogout}
aria-label="Log out"
data-testid="logout-button"
>
<LogOut size={18} />
</ActionIcon>
</Tooltip>
)
}
// ---------------------------------------------------------------------------
// Dark-mode toggle (sits next to the gear / settings icon)
// ---------------------------------------------------------------------------
function ColorSchemeToggle() {
const { setColorScheme } = useMantineColorScheme()
const computed = useComputedColorScheme('light', { getInitialValueInEffect: true })
const isDark = computed === 'dark'
return (
<Tooltip label={isDark ? 'Light mode' : 'Dark mode'}>
<ActionIcon
variant="default"
size="lg"
aria-label="Toggle color scheme"
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
data-testid="color-scheme-toggle"
>
{isDark ? <Sun size={18} /> : <Moon size={18} />}
</ActionIcon>
</Tooltip>
)
}
// ---------------------------------------------------------------------------
// App shell layout (used by all protected pages)
// ---------------------------------------------------------------------------
function AppLayout() {
return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* Top nav */}
<nav
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0.5rem 1rem',
borderBottom: '1px solid var(--mantine-color-default-border)',
}}
>
<Link to="/" style={{ fontWeight: 600, textDecoration: 'none' }}>
Home Automation
</Link>
<Group gap="xs">
{/* Records nav link */}
<Tooltip label="Records">
<ActionIcon
component={Link}
to="/records"
variant="default"
size="lg"
aria-label="Records"
>
<List size={18} />
</ActionIcon>
</Tooltip>
{/* Dark-mode toggle — directly beside the settings gear */}
<ColorSchemeToggle />
{/* Settings — links to config page (§5#10) */}
<Tooltip label="Settings">
<ActionIcon
component={Link}
to="/config"
variant="default"
size="lg"
aria-label="Settings"
>
<Settings size={18} />
</ActionIcon>
</Tooltip>
<LogoutButton />
</Group>
</nav>
{/* Page content */}
<main style={{ flex: 1 }}>
<Outlet />
</main>
</div>
)
}
// ---------------------------------------------------------------------------
// Root app
// ---------------------------------------------------------------------------
export default function App() {
return (
<MantineProvider defaultColorScheme="auto">
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<SessionProvider>
<Routes>
{/* Public routes */}
<Route path="/login" element={<LoginPage />} />
{/* Forced password change — protected (must be logged in) but outside AppLayout */}
<Route
path="/change-password"
element={
<ProtectedRoute>
<ChangePasswordPage />
</ProtectedRoute>
}
/>
{/* Protected routes — all nested under AppLayout */}
<Route
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
>
<Route index element={<HomePage />} />
<Route path="/config" element={<ConfigPage />} />
<Route path="/records" element={<RecordsPage />} />
</Route>
</Routes>
</SessionProvider>
</BrowserRouter>
</QueryClientProvider>
</MantineProvider>
)
}
+62
View File
@@ -0,0 +1,62 @@
/**
* csrfMiddleware 401-handling regression tests.
*
* Bug: clicking Logout (or landing on /login) flooded GET /api/session with 401s
* and the page hung instead of returning to the login screen.
*
* Root cause: the middleware redirected on EVERY 401, including the session
* probe's own 401. The redirect invalidated the ['session'] query, which
* refetched GET /api/session, which 401'd, which redirected again an infinite
* refetch loop. These tests pin the fix: the session probe and the login
* endpoint own their 401s (no redirect); any other endpoint's 401 still
* redirects (session expired mid-use).
*
* We call onResponse() directly (rather than going through apiClient.GET) so the
* test exercises the exact 401 branch without the singleton's relative baseUrl,
* which has no absolute origin to resolve against under jsdom.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Middleware } from 'openapi-fetch'
import { csrfMiddleware, registerLoginRedirect } from './client'
type OnResponse = NonNullable<Middleware['onResponse']>
type OnResponseParams = Parameters<OnResponse>[0]
/** Build the minimal onResponse params for the given schema path + response. */
function params(schemaPath: string, response: Response): OnResponseParams {
return { schemaPath, response, request: new Request('http://test.local' + schemaPath) } as OnResponseParams
}
function response401(): Response {
return new Response(JSON.stringify({ detail: 'unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
const onResponse = csrfMiddleware.onResponse as OnResponse
describe('csrfMiddleware 401 redirect (session-flood regression)', () => {
const redirect = vi.fn()
beforeEach(() => {
redirect.mockReset()
registerLoginRedirect(redirect)
})
it('does NOT redirect when GET /api/session returns 401 (probe owns its 401)', async () => {
await onResponse(params('/api/session', response401()))
expect(redirect).not.toHaveBeenCalled()
})
it('does NOT redirect when POST /api/auth/login returns 401 (bad credentials)', async () => {
await onResponse(params('/api/auth/login', response401()))
expect(redirect).not.toHaveBeenCalled()
})
it('redirects when a normal endpoint returns 401 (session expired mid-use)', async () => {
await onResponse(params('/api/locations', response401()))
expect(redirect).toHaveBeenCalledTimes(1)
})
})
+125
View File
@@ -0,0 +1,125 @@
/**
* Typed API client built on openapi-fetch + generated schema.d.ts.
*
* Middleware contract (orchestrator-decisions.md §11):
* 1. Always send cookies (credentials: "include"; same-origin auto-sends but explicit is clear).
* 2. Non-GET/HEAD requests inject X-CSRF-Token from the csrf holder.
* Exception: POST /api/auth/login skips injection (unauthenticated endpoint).
* 3. 401 responses clear session state + navigate to /login.
* 4. Other non-2xx responses throw an ApiError carrying the parsed JSON body,
* so callers (e.g. SMTP test) can inspect body.result.
*/
import createClient, { type Middleware } from 'openapi-fetch'
import type { paths } from './schema.d.ts'
import { getCsrfToken } from './csrf'
// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------
/** Error thrown for non-2xx, non-401 responses. Carries the parsed JSON body. */
export class ApiError extends Error {
constructor(
public readonly status: number,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public readonly body: any,
) {
super(`API error ${status}`)
this.name = 'ApiError'
}
}
// ---------------------------------------------------------------------------
// Internal navigation helper (avoids React-router import at module level)
// ---------------------------------------------------------------------------
let _navigateToLogin: (() => void) | null = null
/**
* Register a callback that the middleware calls on 401.
* SessionProvider calls this during its setup.
*/
export function registerLoginRedirect(fn: () => void): void {
_navigateToLogin = fn
}
// ---------------------------------------------------------------------------
// CSRF middleware
// ---------------------------------------------------------------------------
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
const LOGIN_PATH = '/api/auth/login'
/**
* Endpoints where a 401 is an EXPECTED, locally-handled outcome and must NOT
* trigger the global login redirect:
* - GET /api/session the session probe; 401 means "not logged in", handled
* by SessionProvider's queryFn (returns null unauthenticated state).
* - POST /api/auth/login bad-credentials check; 401 handled by LoginPage.
*
* Redirecting on these would invalidate the session query, which refetches
* /api/session, which 401s, which redirects again an infinite loop that
* floods GET /api/session after logout and on the login page.
*/
const SESSION_PATH = '/api/session'
const NO_REDIRECT_ON_401 = new Set<string>([SESSION_PATH, LOGIN_PATH])
export const csrfMiddleware: Middleware = {
async onRequest({ request }) {
// Always include cookies (same-origin; explicit for clarity)
// Note: credentials is set at client level; this is belt-and-suspenders doc.
const method = request.method.toUpperCase()
const url = new URL(request.url)
if (WRITE_METHODS.has(method) && url.pathname !== LOGIN_PATH) {
const token = getCsrfToken()
if (token) {
request.headers.set('X-CSRF-Token', token)
}
}
return request
},
async onResponse({ schemaPath, response }) {
if (response.status === 401) {
// The session probe and the login endpoint own their 401s (see
// NO_REDIRECT_ON_401). For any OTHER endpoint, a 401 means the session
// expired mid-use → redirect to /login. Crucially, NOT redirecting on the
// session probe breaks the refetch→401→redirect→refetch flood loop.
if (!NO_REDIRECT_ON_401.has(schemaPath) && _navigateToLogin) {
_navigateToLogin()
}
// Return the original response so callers can handle 401 if needed.
return response
}
if (!response.ok) {
// Parse body and throw; caller can catch ApiError and read .body
let body: unknown
try {
body = await response.clone().json()
} catch {
body = null
}
throw new ApiError(response.status, body)
}
return response
},
}
// ---------------------------------------------------------------------------
// Client instance
// ---------------------------------------------------------------------------
const apiClient = createClient<paths>({
baseUrl: '/',
credentials: 'include',
})
apiClient.use(csrfMiddleware)
export default apiClient
+35
View File
@@ -0,0 +1,35 @@
/**
* Smoke tests for the CSRF token holder.
* These run in isolation (no DOM, no React) and validate the module contract.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { setCsrfToken, getCsrfToken } from './csrf'
describe('csrf holder', () => {
beforeEach(() => {
// Reset to empty between tests by setting empty string
setCsrfToken('')
})
it('returns empty string before any token is set', () => {
expect(getCsrfToken()).toBe('')
})
it('stores and returns the token that was set', () => {
setCsrfToken('test-token-abc123')
expect(getCsrfToken()).toBe('test-token-abc123')
})
it('overwrites a previously set token', () => {
setCsrfToken('first')
setCsrfToken('second')
expect(getCsrfToken()).toBe('second')
})
it('can be reset to empty', () => {
setCsrfToken('some-token')
setCsrfToken('')
expect(getCsrfToken()).toBe('')
})
})
+23
View File
@@ -0,0 +1,23 @@
/**
* Module-level CSRF token holder.
*
* The token is populated by SessionProvider after a successful GET /api/session.
* The fetch client middleware reads it on every non-GET/HEAD request.
*
* Per the project CSRF contract (m2-frontend-v2.md §3.2, orchestrator-decisions.md §3):
* - Server checks presence/non-empty only, does NOT validate the value.
* - Sending an empty-string or stale value will result in a 403; callers must
* ensure setCsrfToken() is called before issuing write requests.
*/
let _csrfToken = ''
/** Store the CSRF token returned by GET /api/session. */
export function setCsrfToken(token: string): void {
_csrfToken = token
}
/** Return the current CSRF token (may be empty string if not yet set). */
export function getCsrfToken(): string {
return _csrfToken
}
+1556
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
/**
* ProtectedRoute renders children when authenticated; redirects to /login otherwise.
*
* Additional gate (M2-T07):
* - If the authenticated user has force_password_change === true, redirect to
* /change-password instead of rendering children. This prevents access to any
* protected page until the password is changed.
* - Shows a loading spinner while the session is still resolving to avoid flash-of-login.
* - On unauthenticated access, preserves the intended destination in location.state.from
* so LoginPage can redirect back after login.
*/
import type { ReactNode } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { Center, Loader } from '@mantine/core'
import { useSession } from './SessionProvider'
interface ProtectedRouteProps {
children: ReactNode
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { status, user } = useSession()
const location = useLocation()
if (status === 'loading') {
// Render a centred spinner while we check the session — avoids a flash to /login.
return (
<Center mih="100vh">
<Loader />
</Center>
)
}
if (status === 'unauthenticated') {
// Preserve the intended destination so LoginPage can redirect back after login.
return <Navigate to="/login" state={{ from: location }} replace />
}
// Authenticated but forced to change password — gate all protected pages.
if (user?.force_password_change && location.pathname !== '/change-password') {
return <Navigate to="/change-password" replace />
}
return <>{children}</>
}
+109
View File
@@ -0,0 +1,109 @@
/**
* SessionProvider fetches GET /api/session once on mount via TanStack Query.
*
* Contract (orchestrator-decisions.md §4, §11):
* - 200 authenticated; calls setCsrfToken(data.csrf_token) so write requests work.
* - 401 unauthenticated (not an error toast; normal state before login).
* - Exposes { user, status } to descendants via useSession().
*
* Also registers the 401 /login redirect with the API client middleware.
*/
import { createContext, useContext, useEffect, type ReactNode } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import apiClient, { registerLoginRedirect } from '../api/client'
import { setCsrfToken } from '../api/csrf'
import type { components } from '../api/schema.d.ts'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type SessionUser = components['schemas']['SessionUser']
type SessionStatus = 'loading' | 'authenticated' | 'unauthenticated'
interface SessionContextValue {
user: SessionUser | null
status: SessionStatus
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const SessionContext = createContext<SessionContextValue>({
user: null,
status: 'loading',
})
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
/** Access the current session from any descendant component. */
export function useSession(): SessionContextValue {
return useContext(SessionContext)
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
interface SessionProviderProps {
children: ReactNode
}
export function SessionProvider({ children }: SessionProviderProps) {
const navigate = useNavigate()
const queryClient = useQueryClient()
// Register the 401 redirect callback with the API client once.
useEffect(() => {
registerLoginRedirect(() => {
// Invalidate the session query so any subscriber re-fetches (→ unauthenticated).
queryClient.invalidateQueries({ queryKey: ['session'] })
navigate('/login', { replace: true })
})
}, [navigate, queryClient])
const { data, status, error } = useQuery({
queryKey: ['session'],
queryFn: async () => {
const res = await apiClient.GET('/api/session')
// openapi-fetch returns { data, error, response }.
// On 401 the middleware already navigates; here data will be undefined.
return res.data ?? null
},
// Don't treat 401 as a React Query "error" — it's a normal unauthenticated state.
retry: false,
staleTime: 1000 * 60 * 5, // 5 minutes
})
// When we get session data, store the CSRF token.
useEffect(() => {
if (data?.csrf_token) {
setCsrfToken(data.csrf_token)
}
}, [data])
let sessionStatus: SessionStatus
if (status === 'pending') {
sessionStatus = 'loading'
} else if (status === 'error' || data === null || !data) {
// 401 returns null from our queryFn; any actual network error → unauthenticated.
sessionStatus = 'unauthenticated'
// Suppress unused variable warning for error in non-401 cases
void error
} else {
sessionStatus = 'authenticated'
}
const value: SessionContextValue = {
user: data?.user ?? null,
status: sessionStatus,
}
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
}
+77
View File
@@ -0,0 +1,77 @@
/**
* TOTP API helpers (M4-T08).
*
* All calls go through the typed apiClient (openapi-fetch + generated schema).
* CSRF tokens are injected automatically by the client middleware for write
* endpoints; GET /api/auth/totp needs no CSRF (read-only).
*
* Security notes:
* - The TotpSetupResponse contains secret + recovery_codes in plaintext.
* These are kept only in component state; callers must NOT write them to
* localStorage, sessionStorage, or log them.
* - After the setup phase is over the data leaves memory when the component
* re-renders (one-time display only).
*/
import apiClient, { ApiError } from '../api/client'
import type { components } from '../api/schema.d.ts'
export type TotpSetupResponse = components['schemas']['TotpSetupResponse']
export type TotpStatusResponse = components['schemas']['TotpStatusResponse']
// ---------------------------------------------------------------------------
// getTotpStatus — GET /api/auth/totp
// ---------------------------------------------------------------------------
/** Return the current TOTP enabled state for the authenticated user. */
export async function getTotpStatus(): Promise<TotpStatusResponse> {
const res = await apiClient.GET('/api/auth/totp')
if (!res.data) {
throw new ApiError(res.response.status, null)
}
return res.data
}
// ---------------------------------------------------------------------------
// setupTotp — POST /api/auth/totp/setup
// ---------------------------------------------------------------------------
/**
* Generate a new pending TOTP secret + recovery codes.
* Returns the one-time plaintext values; caller is responsible for displaying
* them exactly once and never persisting them outside component state.
*/
export async function setupTotp(): Promise<TotpSetupResponse> {
const res = await apiClient.POST('/api/auth/totp/setup')
if (!res.data) {
throw new ApiError(res.response.status, null)
}
return res.data
}
// ---------------------------------------------------------------------------
// enableTotp — POST /api/auth/totp/enable
// ---------------------------------------------------------------------------
/** Confirm TOTP setup with the current 6-digit code from the authenticator app. */
export async function enableTotp(code: string): Promise<void> {
// The endpoint returns 204 on success; openapi-fetch yields { data: undefined }.
await apiClient.POST('/api/auth/totp/enable', { body: { code } })
}
// ---------------------------------------------------------------------------
// disableTotp — POST /api/auth/totp/disable
// ---------------------------------------------------------------------------
/**
* Disable TOTP. Exactly one of password or code must be provided.
* On success the backend clears the secret and all recovery codes.
*/
export async function disableTotp(opts: {
password?: string | null
code?: string | null
}): Promise<void> {
await apiClient.POST('/api/auth/totp/disable', {
body: { password: opts.password ?? null, code: opts.code ?? null },
})
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Entry point mounts the React app into #root.
*/
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
const rootElement = document.getElementById('root')
if (!rootElement) {
throw new Error('Root element #root not found in document')
}
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
)
+118
View File
@@ -0,0 +1,118 @@
/**
* HeatLayers regression test post-walkthrough fix.
*
* Bug: the heat layer's `setLatLngs` was called BEFORE the layer was added to the
* map. A leaflet.heat layer that is not on a map has a null `_map`, and
* `setLatLngs -> redraw` dereferences `_map._animating`, throwing
* "Cannot read properties of null (reading '_animating')" and white-screening
* the whole SPA right after login.
*
* This test exercises the REAL HeatLayers code path (not a wholesale RecordsMap
* mock) and asserts the layer is added to the map BEFORE setLatLngs is called.
* Against the old code (setLatLngs first), the ordering assertion fails.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render } from '@testing-library/react'
const { callLog, setLatLngsSpy, mapAddLayerSpy } = vi.hoisted(() => {
const callLog: string[] = []
const setLatLngsSpy = vi.fn((_pts: unknown) => {
callLog.push('setLatLngs')
})
const mapAddLayerSpy = vi.fn((_layer: unknown) => {
callLog.push('addLayer')
})
return { callLog, setLatLngsSpy, mapAddLayerSpy }
})
// Mock leaflet. heatLayer returns a fake layer whose setLatLngs logs call order;
// Icon/DivIcon/marker exist because RecordsMap.tsx runs icon setup at module load.
vi.mock('leaflet', () => {
class FakeIcon {
constructor(_opts: unknown) {}
static Default = { prototype: {}, mergeOptions: vi.fn() }
}
return {
Icon: FakeIcon,
DivIcon: vi.fn(function FakeDivIcon(_opts: unknown) {
return {}
}),
heatLayer: vi.fn(() => ({ setLatLngs: setLatLngsSpy, setOptions: vi.fn(), addTo: vi.fn() })),
markerClusterGroup: vi.fn(() => ({ addLayer: vi.fn(), addTo: vi.fn(), clearLayers: vi.fn() })),
marker: vi.fn(() => ({ bindTooltip: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis() })),
default: {},
}
})
vi.mock('leaflet.heat', () => ({}))
vi.mock('leaflet.markercluster', () => ({}))
vi.mock('leaflet/dist/images/marker-icon-2x.png', () => ({ default: 'marker-icon-2x.png' }))
vi.mock('leaflet/dist/images/marker-icon.png', () => ({ default: 'marker-icon.png' }))
vi.mock('leaflet/dist/images/marker-shadow.png', () => ({ default: 'marker-shadow.png' }))
vi.mock('leaflet/dist/leaflet.css', () => ({}))
vi.mock('leaflet.markercluster/dist/MarkerCluster.css', () => ({}))
vi.mock('leaflet.markercluster/dist/MarkerCluster.Default.css', () => ({}))
// useMap returns a fake map; hasLayer=false so addLayer is exercised.
vi.mock('react-leaflet', () => ({
MapContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TileLayer: () => null,
useMap: () => ({
addLayer: mapAddLayerSpy,
removeLayer: vi.fn(),
hasLayer: () => false,
getSize: () => ({ x: 800, y: 600 }),
latLngToContainerPoint: () => ({ x: 100, y: 100 }),
on: vi.fn(),
off: vi.fn(),
}),
}))
import { HeatLayers } from './RecordsMap'
import type { HeatPoint } from './mapUtils'
const heatPoints: HeatPoint[] = [
[39.9, 116.4, 1],
[39.91, 116.41, 1],
]
describe('HeatLayers (real code path — regression for null _map crash)', () => {
beforeEach(() => {
vi.clearAllMocks()
callLog.length = 0
})
it('adds the heat layer to the map BEFORE calling setLatLngs', () => {
render(
<HeatLayers
locationHeatPoints={heatPoints}
pooHeatPoints={[]}
showLocationHeat={true}
showPooHeat={false}
/>,
)
// Data was applied...
expect(setLatLngsSpy).toHaveBeenCalledWith(heatPoints)
// ...and the layer was added to the map first. The old buggy order
// (setLatLngs before addLayer) makes this fail.
expect(callLog).toEqual(['addLayer', 'setLatLngs'])
expect(callLog.indexOf('addLayer')).toBeLessThan(callLog.indexOf('setLatLngs'))
})
it('does not call setLatLngs while the layer is hidden (off the map)', () => {
render(
<HeatLayers
locationHeatPoints={heatPoints}
pooHeatPoints={heatPoints}
showLocationHeat={false}
showPooHeat={false}
/>,
)
// Hidden layers are never on the map, so setLatLngs must not run on them.
expect(setLatLngsSpy).not.toHaveBeenCalled()
expect(mapAddLayerSpy).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,246 @@
/**
* ScatterLayer unit test M2-T09 REWORK 1.
*
* This test exercises the REAL ScatterLayer code path (not a wholesale RecordsMap mock).
* It verifies that ScatterLayer uses the imported leaflet namespace (L.markerClusterGroup)
* rather than window.L / globalThis.L, which would silently fail in Vite ESM bundles.
*
* The test:
* - mocks react-leaflet's useMap() to return a fake map object
* - provides a mock markerClusterGroup spy via the leaflet module mock
* - renders ScatterLayer with some points
* - asserts that L.markerClusterGroup was called (i.e. the import path is used)
* - asserts that addLayer was called for each point
* - asserts that clicking a marker invokes onSelectLocation / onSelectPoo
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render } from '@testing-library/react'
import type { ReactNode } from 'react'
// ---------------------------------------------------------------------------
// Use vi.hoisted() to define mocks that are referenced inside vi.mock factories.
// vi.mock() factories are hoisted to the top of the file, so any variables they
// reference must also be hoisted.
// ---------------------------------------------------------------------------
const { markerClusterGroupSpy, fakeAddLayer, fakeMapAddLayer, markerClickHandlers } =
vi.hoisted(() => {
const clickHandlers: Array<() => void> = []
const fakeAddLayer = vi.fn()
const fakeCluster = {
addLayer: fakeAddLayer,
addTo: vi.fn(),
clearLayers: vi.fn(),
}
const markerClusterGroupSpy = vi.fn(() => fakeCluster)
const fakeMapAddLayer = vi.fn()
return { markerClusterGroupSpy, fakeAddLayer, fakeMapAddLayer, markerClickHandlers: clickHandlers }
})
// ---------------------------------------------------------------------------
// Mock leaflet BEFORE importing ScatterLayer.
// We use the hoisted spy so vi.mock factory can reference it safely.
// ---------------------------------------------------------------------------
vi.mock('leaflet', () => {
const markerClusterGroupSpy_ = markerClusterGroupSpy
const markerClickHandlers_ = markerClickHandlers
// Icon must be a real constructor (used as `new Icon(...)`)
class FakeIcon {
constructor(_opts: unknown) {}
static Default = { prototype: {}, mergeOptions: vi.fn() }
}
return {
Icon: FakeIcon,
DivIcon: vi.fn(function FakeDivIcon(_opts: unknown) { return {} }),
heatLayer: vi.fn(() => ({ setLatLngs: vi.fn(), addTo: vi.fn() })),
markerClusterGroup: markerClusterGroupSpy_,
marker: vi.fn((_latlng: unknown, _opts: unknown) => {
return {
bindTooltip: vi.fn().mockReturnThis(),
on: vi.fn((event: string, handler: () => void) => {
if (event === 'click') {
markerClickHandlers_.push(handler)
}
return { bindTooltip: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis() }
}),
}
}),
// `import * as L from 'leaflet'` in RecordsMap.tsx resolves to this module.
// Vitest's module mock exposes all named exports as the namespace object,
// so markerClusterGroup at the top level IS accessible as L.markerClusterGroup.
default: {
markerClusterGroup: markerClusterGroupSpy_,
},
}
})
vi.mock('leaflet.heat', () => ({}))
vi.mock('leaflet.markercluster', () => ({}))
// Mock image imports
vi.mock('leaflet/dist/images/marker-icon-2x.png', () => ({ default: 'marker-icon-2x.png' }))
vi.mock('leaflet/dist/images/marker-icon.png', () => ({ default: 'marker-icon.png' }))
vi.mock('leaflet/dist/images/marker-shadow.png', () => ({ default: 'marker-shadow.png' }))
// Mock CSS imports
vi.mock('leaflet/dist/leaflet.css', () => ({}))
vi.mock('leaflet.markercluster/dist/MarkerCluster.css', () => ({}))
vi.mock('leaflet.markercluster/dist/MarkerCluster.Default.css', () => ({}))
// ---------------------------------------------------------------------------
// Mock react-leaflet: MapContainer renders children, useMap returns fake map.
// ---------------------------------------------------------------------------
vi.mock('react-leaflet', () => ({
MapContainer: ({ children }: { children: ReactNode }) => (
<div data-testid="map-container">{children}</div>
),
TileLayer: () => null,
useMap: () => ({
addLayer: fakeMapAddLayer,
removeLayer: vi.fn(),
hasLayer: vi.fn(() => false),
}),
}))
// ---------------------------------------------------------------------------
// Import ScatterLayer AFTER mocks are set up.
// ---------------------------------------------------------------------------
import { ScatterLayer } from './RecordsMap'
import type { LocationMapPoint, PooMapPoint } from './mapUtils'
import type { LocationRecord, PooRecord } from '../records'
// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------
const locationRecord: LocationRecord = {
person: 'alice',
datetime: '2026-01-15T10:00:00Z',
latitude: 39.9,
longitude: 116.4,
altitude: null,
}
const locationPoints: LocationMapPoint[] = [
{ lat: 39.9, lng: 116.4, record: locationRecord },
]
const pooRecord: PooRecord = {
timestamp: '2026-01-20T09:00:00Z',
status: 'done',
latitude: 39.91,
longitude: 116.41,
}
const pooPoints: PooMapPoint[] = [
{ lat: 39.91, lng: 116.41, record: pooRecord },
]
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ScatterLayer (real code path — not mocked RecordsMap)', () => {
beforeEach(() => {
vi.clearAllMocks()
markerClickHandlers.length = 0
})
it('calls L.markerClusterGroup (imported namespace) when showScatter=true', () => {
render(
<ScatterLayer
locationScatterPoints={locationPoints}
pooScatterPoints={[]}
showScatter={true}
onSelectLocation={vi.fn()}
onSelectPoo={vi.fn()}
/>,
)
// KEY assertion: markerClusterGroup was called via the IMPORTED namespace.
// With the old window.L / globalThis.L approach, this spy would never be
// invoked because window.L is undefined in Vite ESM bundles.
expect(markerClusterGroupSpy).toHaveBeenCalledOnce()
expect(markerClusterGroupSpy).toHaveBeenCalledWith({
maxClusterRadius: 50,
showCoverageOnHover: false,
})
})
it('calls cluster group addLayer for each location and poo scatter point', () => {
render(
<ScatterLayer
locationScatterPoints={locationPoints}
pooScatterPoints={pooPoints}
showScatter={true}
onSelectLocation={vi.fn()}
onSelectPoo={vi.fn()}
/>,
)
// One addLayer call per point (1 location + 1 poo = 2).
expect(fakeAddLayer).toHaveBeenCalledTimes(2)
// The cluster group itself must be added to the map.
const fakeCluster = markerClusterGroupSpy.mock.results[0]?.value
expect(fakeMapAddLayer).toHaveBeenCalledWith(fakeCluster)
})
it('does NOT create cluster group when showScatter=false', () => {
render(
<ScatterLayer
locationScatterPoints={locationPoints}
pooScatterPoints={pooPoints}
showScatter={false}
onSelectLocation={vi.fn()}
onSelectPoo={vi.fn()}
/>,
)
expect(markerClusterGroupSpy).not.toHaveBeenCalled()
expect(fakeAddLayer).not.toHaveBeenCalled()
})
it('invokes onSelectLocation when a location marker is clicked', () => {
const onSelectLocation = vi.fn()
render(
<ScatterLayer
locationScatterPoints={locationPoints}
pooScatterPoints={[]}
showScatter={true}
onSelectLocation={onSelectLocation}
onSelectPoo={vi.fn()}
/>,
)
// At least one marker click handler should have been registered.
expect(markerClickHandlers.length).toBeGreaterThan(0)
// Simulate click on the first (location) marker.
markerClickHandlers[0]()
expect(onSelectLocation).toHaveBeenCalledOnce()
expect(onSelectLocation).toHaveBeenCalledWith(locationRecord)
})
it('invokes onSelectPoo when a poo marker is clicked', () => {
const onSelectPoo = vi.fn()
render(
<ScatterLayer
locationScatterPoints={[]}
pooScatterPoints={pooPoints}
showScatter={true}
onSelectLocation={vi.fn()}
onSelectPoo={onSelectPoo}
/>,
)
expect(markerClickHandlers.length).toBeGreaterThan(0)
markerClickHandlers[0]()
expect(onSelectPoo).toHaveBeenCalledOnce()
expect(onSelectPoo).toHaveBeenCalledWith(pooRecord)
})
})
+345
View File
@@ -0,0 +1,345 @@
/**
* RecordsMap self-contained Leaflet map component (M2-T09).
*
* THIS IS THE ONLY MODULE IN THE APP THAT IMPORTS LEAFLET / REACT-LEAFLET.
* All data fetching and state lives outside; this component receives typed props.
*/
import { useEffect, useRef, useCallback } from 'react'
import { MapContainer, TileLayer, useMap } from 'react-leaflet'
import * as L from 'leaflet'
import {
Icon,
DivIcon,
marker as leafletMarker,
heatLayer as leafletHeatLayer,
type HeatLayer,
} from 'leaflet'
// Leaflet CSS — must be imported once; this component is the single place.
import 'leaflet/dist/leaflet.css'
import 'leaflet.markercluster/dist/MarkerCluster.css'
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
// Side-effect imports (augment L with heatLayer and markerClusterGroup)
import 'leaflet.heat'
import 'leaflet.markercluster'
import { peakGridCount } from './mapUtils'
import type { HeatPoint, LocationMapPoint, PooMapPoint } from './mapUtils'
import type { LocationRecord, PooRecord } from '../records'
// Fix default Leaflet marker icon paths broken by Vite asset handling.
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'
import markerIcon from 'leaflet/dist/images/marker-icon.png'
import markerShadow from 'leaflet/dist/images/marker-shadow.png'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (Icon.Default.prototype as any)._getIconUrl
Icon.Default.mergeOptions({
iconRetinaUrl: markerIcon2x,
iconUrl: markerIcon,
shadowUrl: markerShadow,
})
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
export interface RecordsMapProps {
locationHeatPoints: HeatPoint[]
pooHeatPoints: HeatPoint[]
locationScatterPoints: LocationMapPoint[]
pooScatterPoints: PooMapPoint[]
showLocationHeat: boolean
showPooHeat: boolean
showScatter: boolean
onSelectLocation?: (record: LocationRecord) => void
onSelectPoo?: (record: PooRecord) => void
/** Map container height (CSS value). Default: '100%'. */
height?: string
/** Use dark base tiles to match the app's dark color scheme. */
dark?: boolean
}
// OSM (light) and CARTO dark_all (dark) raster tiles — both zero-key.
const LIGHT_TILES = {
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}
const DARK_TILES = {
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
}
// ---------------------------------------------------------------------------
// Inner child: Heat layers (uses useMap hook — must be inside MapContainer)
// ---------------------------------------------------------------------------
interface HeatLayerChildProps {
locationHeatPoints: HeatPoint[]
pooHeatPoints: HeatPoint[]
showLocationHeat: boolean
showPooHeat: boolean
}
// Heat layer geometry. maxZoom:0 makes leaflet.heat's zoom intensity factor f=1
// at every zoom, so accumulated per-cell intensity equals the raw point count —
// which lets us normalize with a pixel-grid count below.
const LOC_HEAT = { radius: 20, blur: 15 }
const POO_HEAT = { radius: 25, blur: 18 }
/**
* leaflet.heat `max` (normalization denominator) for the CURRENT viewport:
* project the points that are visible (within the map size + a radius margin) to
* container pixels, then count the densest pixel cell using leaflet.heat's own
* grid (cell = (radius+blur)/2). The densest visible cluster maps to the hot
* color; recomputing on every zoom/pan keeps it normalized to what's on screen.
*/
function viewportHeatMax(map: L.Map, points: HeatPoint[], radius: number, blur: number): number {
if (points.length === 0) return 1
const cell = (radius + blur) / 2
const size = map.getSize()
const margin = radius + blur
const coords: Array<[number, number]> = []
for (let i = 0; i < points.length; i++) {
const p = map.latLngToContainerPoint([points[i][0], points[i][1]])
if (p.x < -margin || p.y < -margin || p.x > size.x + margin || p.y > size.y + margin) continue
coords.push([p.x, p.y])
}
return peakGridCount(coords, cell)
}
export function HeatLayers({
locationHeatPoints,
pooHeatPoints,
showLocationHeat,
showPooHeat,
}: HeatLayerChildProps) {
const map = useMap()
const locationLayerRef = useRef<HeatLayer | null>(null)
const pooLayerRef = useRef<HeatLayer | null>(null)
// Latest data/visibility in refs so the once-registered map move/zoom handler
// re-normalizes against the current points without re-subscribing.
const locPointsRef = useRef(locationHeatPoints)
const pooPointsRef = useRef(pooHeatPoints)
const showLocRef = useRef(showLocationHeat)
const showPooRef = useRef(showPooHeat)
useEffect(() => {
locPointsRef.current = locationHeatPoints
pooPointsRef.current = pooHeatPoints
showLocRef.current = showLocationHeat
showPooRef.current = showPooHeat
})
// Location heat layer
useEffect(() => {
if (!locationLayerRef.current) {
locationLayerRef.current = leafletHeatLayer([], {
...LOC_HEAT,
maxZoom: 0,
gradient: { 0.4: 'blue', 0.65: 'lime', 1: 'red' },
})
}
const layer = locationLayerRef.current
if (showLocationHeat) {
// Add the layer to the map BEFORE setLatLngs. A heat layer that is not on
// a map has a null `_map`, and `setLatLngs -> redraw` dereferences
// `_map._animating`, which throws and white-screens the SPA.
if (!map.hasLayer(layer)) map.addLayer(layer)
layer.setLatLngs(locationHeatPoints)
layer.setOptions({ max: viewportHeatMax(map, locationHeatPoints, LOC_HEAT.radius, LOC_HEAT.blur) })
} else {
if (map.hasLayer(layer)) map.removeLayer(layer)
}
return () => {
if (map.hasLayer(layer)) map.removeLayer(layer)
}
}, [map, locationHeatPoints, showLocationHeat])
// Poo heat layer
useEffect(() => {
if (!pooLayerRef.current) {
pooLayerRef.current = leafletHeatLayer([], {
...POO_HEAT,
maxZoom: 0,
// High-frequency poo spots reach red (per request); mid tones stay
// yellow/orange to distinguish from the location layer.
gradient: { 0.4: 'yellow', 0.7: 'orange', 1: 'red' },
})
}
const layer = pooLayerRef.current
if (showPooHeat) {
// Add to the map before setLatLngs (see the location heat layer above).
if (!map.hasLayer(layer)) map.addLayer(layer)
layer.setLatLngs(pooHeatPoints)
layer.setOptions({ max: viewportHeatMax(map, pooHeatPoints, POO_HEAT.radius, POO_HEAT.blur) })
} else {
if (map.hasLayer(layer)) map.removeLayer(layer)
}
return () => {
if (map.hasLayer(layer)) map.removeLayer(layer)
}
}, [map, pooHeatPoints, showPooHeat])
// Re-normalize each visible layer to the viewport peak on pan/zoom.
useEffect(() => {
const recompute = () => {
const loc = locationLayerRef.current
if (loc && showLocRef.current && map.hasLayer(loc)) {
loc.setOptions({ max: viewportHeatMax(map, locPointsRef.current, LOC_HEAT.radius, LOC_HEAT.blur) })
}
const poo = pooLayerRef.current
if (poo && showPooRef.current && map.hasLayer(poo)) {
poo.setOptions({ max: viewportHeatMax(map, pooPointsRef.current, POO_HEAT.radius, POO_HEAT.blur) })
}
}
map.on('moveend', recompute)
map.on('zoomend', recompute)
return () => {
map.off('moveend', recompute)
map.off('zoomend', recompute)
}
}, [map])
return null
}
// ---------------------------------------------------------------------------
// Inner child: Scatter / cluster layer
// ---------------------------------------------------------------------------
interface ScatterLayerChildProps {
locationScatterPoints: LocationMapPoint[]
pooScatterPoints: PooMapPoint[]
showScatter: boolean
onSelectLocation?: (record: LocationRecord) => void
onSelectPoo?: (record: PooRecord) => void
}
const locationIcon = new Icon({
iconUrl: markerIcon,
iconRetinaUrl: markerIcon2x,
shadowUrl: markerShadow,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
})
const pooIcon = new DivIcon({
html: '<div style="font-size:20px;line-height:1;">💩</div>',
className: '',
iconSize: [24, 24],
iconAnchor: [12, 12],
})
export function ScatterLayer({
locationScatterPoints,
pooScatterPoints,
showScatter,
onSelectLocation,
onSelectPoo,
}: ScatterLayerChildProps) {
const map = useMap()
const clusterGroupRef = useRef<L.MarkerClusterGroup | null>(null)
const rebuild = useCallback(() => {
if (clusterGroupRef.current) {
map.removeLayer(clusterGroupRef.current)
clusterGroupRef.current = null
}
if (!showScatter) return
// markerClusterGroup is augmented onto the imported L namespace by the
// leaflet.markercluster side-effect import above. Using the imported
// namespace (not window.L) is what works in Vite ESM bundles.
const group = L.markerClusterGroup({ maxClusterRadius: 50, showCoverageOnHover: false })
for (const pt of locationScatterPoints) {
const m = leafletMarker([pt.lat, pt.lng], { icon: locationIcon })
m.bindTooltip(`${pt.record.person}<br/>${pt.record.datetime}`, { sticky: true })
if (onSelectLocation) m.on('click', () => onSelectLocation(pt.record))
group.addLayer(m)
}
for (const pt of pooScatterPoints) {
const m = leafletMarker([pt.lat, pt.lng], { icon: pooIcon })
m.bindTooltip(`${pt.record.timestamp}<br/>${pt.record.status}`, { sticky: true })
if (onSelectPoo) m.on('click', () => onSelectPoo(pt.record))
group.addLayer(m)
}
map.addLayer(group)
clusterGroupRef.current = group
}, [map, locationScatterPoints, pooScatterPoints, showScatter, onSelectLocation, onSelectPoo])
useEffect(() => {
rebuild()
return () => {
if (clusterGroupRef.current) {
map.removeLayer(clusterGroupRef.current)
clusterGroupRef.current = null
}
}
}, [rebuild, map])
return null
}
// ---------------------------------------------------------------------------
// Public component
// ---------------------------------------------------------------------------
/** Default map center: Beijing area. */
const DEFAULT_CENTER: [number, number] = [39.9, 116.4]
const DEFAULT_ZOOM = 11
export function RecordsMap({
locationHeatPoints,
pooHeatPoints,
locationScatterPoints,
pooScatterPoints,
showLocationHeat,
showPooHeat,
showScatter,
onSelectLocation,
onSelectPoo,
height = '100%',
dark = false,
}: RecordsMapProps) {
const tiles = dark ? DARK_TILES : LIGHT_TILES
return (
<MapContainer
center={DEFAULT_CENTER}
zoom={DEFAULT_ZOOM}
style={{ height, width: '100%', background: dark ? '#1a1b1e' : undefined }}
data-testid="records-map"
>
{/* key forces a clean tile-layer swap when the color scheme changes */}
<TileLayer key={tiles.url} attribution={tiles.attribution} url={tiles.url} />
<HeatLayers
locationHeatPoints={locationHeatPoints}
pooHeatPoints={pooHeatPoints}
showLocationHeat={showLocationHeat}
showPooHeat={showPooHeat}
/>
<ScatterLayer
locationScatterPoints={locationScatterPoints}
pooScatterPoints={pooScatterPoints}
showScatter={showScatter}
onSelectLocation={onSelectLocation}
onSelectPoo={onSelectPoo}
/>
</MapContainer>
)
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Tests for peakGridCount the pure pixel-grid peak counter used to normalize
* each heat layer to the densest cell visible in the current viewport.
*/
import { describe, it, expect } from 'vitest'
import { peakGridCount } from './mapUtils'
describe('peakGridCount', () => {
it('returns 1 for empty input (no divide-by-zero)', () => {
expect(peakGridCount([], 10)).toBe(1)
})
it('counts coords sharing a grid cell and returns the peak', () => {
const coords: Array<[number, number]> = [
[0, 0],
[3, 4], // same 10px cell as [0,0]
[9, 9], // same 10px cell
[100, 100], // different cell
]
expect(peakGridCount(coords, 10)).toBe(3)
})
it('separates coords into different cells by cellSize', () => {
const coords: Array<[number, number]> = [
[0, 0],
[10, 0], // next cell over at cellSize 10
[20, 0], // next again
]
expect(peakGridCount(coords, 10)).toBe(1)
})
it('a denser cluster yields a larger peak (drives per-layer normalization)', () => {
const dense: Array<[number, number]> = Array.from({ length: 12 }, () => [5, 5] as [number, number])
const sparse: Array<[number, number]> = [
[5, 5],
[5, 5],
]
expect(peakGridCount(dense, 10)).toBe(12)
expect(peakGridCount(sparse, 10)).toBe(2)
})
})
+21
View File
@@ -0,0 +1,21 @@
/**
* Public surface of the map module (M2-T09).
* Only RecordsMap.tsx imports leaflet external code should not.
*/
export { RecordsMap } from './RecordsMap'
export type { RecordsMapProps } from './RecordsMap'
export {
locationsToHeatPoints,
pooToHeatPoints,
locationsToMapPoints,
pooToMapPoints,
filterPooByTimeWindow,
daysAgoISO,
nowISO,
computeCenter,
TIME_PRESETS,
presetRange,
shiftRange,
} from './mapUtils'
export type { HeatPoint, LocationMapPoint, PooMapPoint, TimePreset } from './mapUtils'
+40
View File
@@ -0,0 +1,40 @@
/**
* Ambient type declarations for leaflet.heat (no @types package available).
*
* This file must be a MODULE (has a top-level export) so that `declare module 'leaflet'`
* is treated as an AUGMENTATION of the existing leaflet types, not a replacement.
* Without the export, the `declare module 'leaflet'` block would shadow all of @types/leaflet.
*/
// This empty export makes the file a module, enabling proper augmentation semantics.
export {}
// Augment the 'leaflet' module to add heatLayer and HeatLayer types.
declare module 'leaflet' {
type HeatLatLngTuple = [number, number] | [number, number, number]
interface HeatLayerOptions {
minOpacity?: number
maxZoom?: number
max?: number
radius?: number
blur?: number
gradient?: Record<number, string>
}
class HeatLayer extends Layer {
setLatLngs(latlngs: HeatLatLngTuple[]): this
addLatLng(latlng: HeatLatLngTuple): this
setOptions(options: HeatLayerOptions): this
redraw(): this
}
function heatLayer(latlngs: HeatLatLngTuple[], options?: HeatLayerOptions): HeatLayer
}
// Declare leaflet.heat as a side-effect-only module.
declare module 'leaflet.heat' {
// Side-effect: augments the Leaflet global with the heatLayer plugin.
const _: undefined
export default _
}
+196
View File
@@ -0,0 +1,196 @@
/**
* Unit tests for mapUtils.ts pure logic, no leaflet, runs in jsdom.
*/
import { describe, it, expect } from 'vitest'
import {
locationsToHeatPoints,
pooToHeatPoints,
locationsToMapPoints,
pooToMapPoints,
filterPooByTimeWindow,
computeCenter,
daysAgoISO,
} from './mapUtils'
import type { LocationRecord, PooRecord } from '../records'
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const loc1: LocationRecord = {
person: 'alice',
datetime: '2026-01-15T10:00:00Z',
latitude: 39.9,
longitude: 116.4,
altitude: 50,
}
const loc2: LocationRecord = {
person: 'alice',
datetime: '2026-01-20T12:00:00Z',
latitude: 39.95,
longitude: 116.45,
altitude: null,
}
const poo1: PooRecord = {
timestamp: '2026-01-10T08:00:00Z',
status: 'done',
latitude: 39.91,
longitude: 116.41,
}
const poo2: PooRecord = {
timestamp: '2026-01-20T09:00:00Z',
status: 'done',
latitude: 39.92,
longitude: 116.42,
}
const poo3: PooRecord = {
timestamp: '2026-02-01T09:00:00Z',
status: 'done',
latitude: 39.93,
longitude: 116.43,
}
// ---------------------------------------------------------------------------
// locationsToHeatPoints
// ---------------------------------------------------------------------------
describe('locationsToHeatPoints', () => {
it('converts records to [lat, lng, 1] tuples', () => {
const pts = locationsToHeatPoints([loc1, loc2])
expect(pts).toHaveLength(2)
expect(pts[0]).toEqual([39.9, 116.4, 1])
expect(pts[1]).toEqual([39.95, 116.45, 1])
})
it('returns empty array for empty input', () => {
expect(locationsToHeatPoints([])).toEqual([])
})
})
// ---------------------------------------------------------------------------
// pooToHeatPoints
// ---------------------------------------------------------------------------
describe('pooToHeatPoints', () => {
it('converts poo records to heat points', () => {
const pts = pooToHeatPoints([poo1])
expect(pts).toHaveLength(1)
expect(pts[0]).toEqual([39.91, 116.41, 1])
})
})
// ---------------------------------------------------------------------------
// locationsToMapPoints
// ---------------------------------------------------------------------------
describe('locationsToMapPoints', () => {
it('attaches original record to each point', () => {
const pts = locationsToMapPoints([loc1])
expect(pts).toHaveLength(1)
expect(pts[0].lat).toBe(39.9)
expect(pts[0].lng).toBe(116.4)
expect(pts[0].record).toBe(loc1)
})
})
// ---------------------------------------------------------------------------
// pooToMapPoints
// ---------------------------------------------------------------------------
describe('pooToMapPoints', () => {
it('attaches original poo record to each point', () => {
const pts = pooToMapPoints([poo1])
expect(pts[0].record).toBe(poo1)
})
})
// ---------------------------------------------------------------------------
// filterPooByTimeWindow — client-side time filter
// ---------------------------------------------------------------------------
describe('filterPooByTimeWindow', () => {
const records = [poo1, poo2, poo3]
// timestamps: 2026-01-10, 2026-01-20, 2026-02-01
it('returns all records when start and end are both null', () => {
expect(filterPooByTimeWindow(records, null, null)).toHaveLength(3)
})
it('filters by start (inclusive)', () => {
const result = filterPooByTimeWindow(records, '2026-01-15T00:00:00Z', null)
expect(result).toHaveLength(2)
expect(result.map((r) => r.timestamp)).toContain('2026-01-20T09:00:00Z')
expect(result.map((r) => r.timestamp)).toContain('2026-02-01T09:00:00Z')
})
it('filters by end (inclusive)', () => {
const result = filterPooByTimeWindow(records, null, '2026-01-20T09:00:00Z')
expect(result).toHaveLength(2)
expect(result.map((r) => r.timestamp)).toContain('2026-01-10T08:00:00Z')
expect(result.map((r) => r.timestamp)).toContain('2026-01-20T09:00:00Z')
})
it('filters by both start and end', () => {
const result = filterPooByTimeWindow(
records,
'2026-01-15T00:00:00Z',
'2026-01-25T00:00:00Z',
)
expect(result).toHaveLength(1)
expect(result[0].timestamp).toBe('2026-01-20T09:00:00Z')
})
it('returns empty when no records match', () => {
const result = filterPooByTimeWindow(records, '2027-01-01T00:00:00Z', null)
expect(result).toHaveLength(0)
})
it('includes records exactly at start boundary', () => {
const result = filterPooByTimeWindow(records, '2026-01-10T08:00:00Z', null)
expect(result.map((r) => r.timestamp)).toContain('2026-01-10T08:00:00Z')
})
it('includes records exactly at end boundary', () => {
const result = filterPooByTimeWindow(records, null, '2026-02-01T09:00:00Z')
expect(result.map((r) => r.timestamp)).toContain('2026-02-01T09:00:00Z')
})
})
// ---------------------------------------------------------------------------
// computeCenter
// ---------------------------------------------------------------------------
describe('computeCenter', () => {
it('returns null for empty array', () => {
expect(computeCenter([])).toBeNull()
})
it('returns the point for a single-element array', () => {
const result = computeCenter([{ lat: 10, lng: 20 }])
expect(result).toEqual([10, 20])
})
it('returns the average of multiple points', () => {
const result = computeCenter([
{ lat: 0, lng: 0 },
{ lat: 4, lng: 6 },
])
expect(result).toEqual([2, 3])
})
})
// ---------------------------------------------------------------------------
// daysAgoISO
// ---------------------------------------------------------------------------
describe('daysAgoISO', () => {
it('returns a valid ISO string in the past', () => {
const result = daysAgoISO(7)
expect(typeof result).toBe('string')
const d = new Date(result)
expect(isNaN(d.getTime())).toBe(false)
expect(d.getTime()).toBeLessThan(Date.now())
})
})
+184
View File
@@ -0,0 +1,184 @@
/**
* Pure data-transform utilities for the map view (M2-T09).
* No leaflet imports these functions are unit-testable in jsdom.
*/
import type { LocationRecord, PooRecord } from '../records'
/** A heat point for L.heatLayer: [lat, lng, intensity]. */
export type HeatPoint = [number, number, number]
/** Map point with attached source record for click-to-edit. */
export interface LocationMapPoint {
lat: number
lng: number
record: LocationRecord
}
export interface PooMapPoint {
lat: number
lng: number
record: PooRecord
}
// ---------------------------------------------------------------------------
// Transforms
// ---------------------------------------------------------------------------
/**
* Convert location records to heat points.
* All points get intensity=1; callers can adjust if needed.
*/
export function locationsToHeatPoints(records: LocationRecord[]): HeatPoint[] {
return records.map((r) => [r.latitude, r.longitude, 1])
}
/**
* Convert poo records to heat points.
*/
export function pooToHeatPoints(records: PooRecord[]): HeatPoint[] {
return records.map((r) => [r.latitude, r.longitude, 1])
}
/**
* Peak number of 2D coordinates that fall into the same `cellSize`-sized grid
* cell. Pure + leaflet-free so it is unit-testable.
*
* Used by the map heat normalization: project the VISIBLE points to screen
* pixels (in the map component), then this returns the densest pixel cell's
* count, which becomes leaflet.heat's `max`. With maxZoom:0 (intensity factor
* f=1) the accumulated per-cell value equals this count, so the densest visible
* cluster maps to the hot color recomputed on every zoom/pan so it always
* normalizes within the current viewport. Returns at least 1.
*/
export function peakGridCount(coords: Array<[number, number]>, cellSize: number): number {
if (coords.length === 0) return 1
const g = Math.max(1, cellSize)
const counts = new Map<string, number>()
let peak = 1
for (const [x, y] of coords) {
const key = `${Math.floor(x / g)}:${Math.floor(y / g)}`
const next = (counts.get(key) ?? 0) + 1
counts.set(key, next)
if (next > peak) peak = next
}
return peak
}
/**
* Convert location records to map points (for scatter layer).
*/
export function locationsToMapPoints(records: LocationRecord[]): LocationMapPoint[] {
return records.map((r) => ({ lat: r.latitude, lng: r.longitude, record: r }))
}
/**
* Convert poo records to map points (for scatter layer).
*/
export function pooToMapPoints(records: PooRecord[]): PooMapPoint[] {
return records.map((r) => ({ lat: r.latitude, lng: r.longitude, record: r }))
}
// ---------------------------------------------------------------------------
// Client-side time-window filter (for poo records — the endpoint has no server filter)
// ---------------------------------------------------------------------------
/**
* Filter poo records to those whose timestamp falls within [start, end] (inclusive).
* start and end are ISO8601 strings (e.g. "2026-01-01T00:00:00Z").
* If start or end is null, that bound is open (no filtering on that side).
*/
export function filterPooByTimeWindow(
records: PooRecord[],
start: string | null,
end: string | null,
): PooRecord[] {
if (!start && !end) return records
return records.filter((r) => {
const ts = r.timestamp
if (start && ts < start) return false
if (end && ts > end) return false
return true
})
}
// ---------------------------------------------------------------------------
// Default time window helpers
// ---------------------------------------------------------------------------
/** Returns ISO8601 string for N days ago from now (UTC). */
export function daysAgoISO(days: number): string {
const d = new Date()
d.setUTCDate(d.getUTCDate() - days)
return d.toISOString()
}
/** Returns ISO8601 string for now (UTC). */
export function nowISO(): string {
return new Date().toISOString()
}
/** Compute a bounding center from an array of lat/lng points. Returns null if empty. */
export function computeCenter(
points: Array<{ lat: number; lng: number }>,
): [number, number] | null {
if (points.length === 0) return null
const sumLat = points.reduce((s, p) => s + p.lat, 0)
const sumLng = points.reduce((s, p) => s + p.lng, 0)
return [sumLat / points.length, sumLng / points.length]
}
// ---------------------------------------------------------------------------
// Quick time-range presets + window shifting (Grafana-style)
// ---------------------------------------------------------------------------
const HOUR_MS = 3_600_000
const DAY_MS = 24 * HOUR_MS
/** A quick-range preset: a label + a span in milliseconds (month/year approximated). */
export interface TimePreset {
value: string
label: string
spanMs: number
}
export const TIME_PRESETS: TimePreset[] = [
{ value: '24h', label: 'Past 24 hours', spanMs: 24 * HOUR_MS },
{ value: '1w', label: 'Past 1 week', spanMs: 7 * DAY_MS },
{ value: '2w', label: 'Past 2 weeks', spanMs: 14 * DAY_MS },
{ value: '1mo', label: 'Past 1 month', spanMs: 30 * DAY_MS },
{ value: '6mo', label: 'Past 6 months', spanMs: 182 * DAY_MS },
{ value: '1y', label: 'Past 1 year', spanMs: 365 * DAY_MS },
{ value: '5y', label: 'Past 5 years', spanMs: 5 * 365 * DAY_MS },
]
/** ISO8601 with second precision, no milliseconds: "YYYY-MM-DDTHH:MM:SSZ". */
function isoSeconds(d: Date): string {
return d.toISOString().slice(0, 19) + 'Z'
}
/**
* Compute a [start, end] window of width `spanMs` ending at `now`.
* Used when the user picks a quick-range preset.
*/
export function presetRange(spanMs: number, now: Date = new Date()): { start: string; end: string } {
return { start: isoSeconds(new Date(now.getTime() - spanMs)), end: isoSeconds(now) }
}
/**
* Shift a [start, end] window by its OWN span. direction = -1 moves earlier
* (back in time), +1 moves later. The window width is preserved.
*/
export function shiftRange(
startISO: string,
endISO: string,
direction: -1 | 1,
): { start: string; end: string } {
const startMs = Date.parse(startISO)
const endMs = Date.parse(endISO)
const span = endMs - startMs
return {
start: isoSeconds(new Date(startMs + direction * span)),
end: isoSeconds(new Date(endMs + direction * span)),
}
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Tests for the quick-range preset + window-shift helpers (Grafana-style).
*/
import { describe, it, expect } from 'vitest'
import { TIME_PRESETS, presetRange, shiftRange } from './mapUtils'
describe('TIME_PRESETS', () => {
it('exposes the 7 expected quick ranges in order', () => {
expect(TIME_PRESETS.map((p) => p.value)).toEqual([
'24h',
'1w',
'2w',
'1mo',
'6mo',
'1y',
'5y',
])
})
})
describe('presetRange', () => {
const now = new Date('2026-06-13T12:00:00Z')
it('ends at now and spans the given duration (24h)', () => {
const { start, end } = presetRange(24 * 3_600_000, now)
expect(end).toBe('2026-06-13T12:00:00Z')
expect(start).toBe('2026-06-12T12:00:00Z')
})
it('spans a week', () => {
const { start, end } = presetRange(7 * 24 * 3_600_000, now)
expect(end).toBe('2026-06-13T12:00:00Z')
expect(start).toBe('2026-06-06T12:00:00Z')
})
it('emits second-precision ISO with no milliseconds', () => {
const { start, end } = presetRange(3_600_000, now)
expect(start).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
expect(end).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
})
})
describe('shiftRange', () => {
it('moves a 24h window back by 24h when direction = -1', () => {
const { start, end } = shiftRange('2026-06-12T12:00:00Z', '2026-06-13T12:00:00Z', -1)
expect(start).toBe('2026-06-11T12:00:00Z')
expect(end).toBe('2026-06-12T12:00:00Z')
})
it('moves a 24h window forward by 24h when direction = +1', () => {
const { start, end } = shiftRange('2026-06-12T12:00:00Z', '2026-06-13T12:00:00Z', 1)
expect(start).toBe('2026-06-13T12:00:00Z')
expect(end).toBe('2026-06-14T12:00:00Z')
})
it('shifts by the window OWN span (a 1-week window moves a week)', () => {
const { start, end } = shiftRange('2026-06-06T12:00:00Z', '2026-06-13T12:00:00Z', -1)
expect(start).toBe('2026-05-30T12:00:00Z')
expect(end).toBe('2026-06-06T12:00:00Z')
})
it('is reversible: shift back then forward returns to the original window', () => {
const orig = { start: '2026-06-06T12:00:00Z', end: '2026-06-13T12:00:00Z' }
const back = shiftRange(orig.start, orig.end, -1)
const fwd = shiftRange(back.start, back.end, 1)
expect(fwd).toEqual(orig)
})
})
@@ -0,0 +1,193 @@
/**
* Tests for ChangePasswordPage (M2-T07 rework-1).
*
* Strategy: vi.mock the apiClient and useSession modules so we can control
* POST /api/auth/password responses and session state without a real server.
*
* Coverage:
* 1. Renders the change-password form when user has force_password_change=true.
* 2. Successful password change navigates to '/' (proceeds into the app).
* 3. Client-side mismatch shows error, does NOT call the API.
* 4. API 400 error shows generic error, stays on form.
* 5. Guard: non-forced user visiting /change-password redirected to '/'.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor, fireEvent } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
import { ChangePasswordPage } from './ChangePasswordPage'
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
const mockPost = vi.fn()
vi.mock('../api/client', () => ({
default: {
POST: (...args: unknown[]) => mockPost(...args),
GET: vi.fn(),
},
ApiError: class ApiError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown) {
super(`API error ${status}`)
this.name = 'ApiError'
this.status = status
this.body = body
}
},
registerLoginRedirect: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Mock useSession — default: forced-change user
// ---------------------------------------------------------------------------
const mockUseSession = vi.fn(() => ({
status: 'authenticated' as 'loading' | 'authenticated' | 'unauthenticated',
user: { username: 'admin', force_password_change: true } as
| null
| { username: string; force_password_change: boolean },
}))
vi.mock('../auth/SessionProvider', () => ({
useSession: () => mockUseSession(),
SessionProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderChangePw(initialPath = '/change-password') {
return renderWithProviders(<ChangePasswordPage />, {
initialPath,
routes: [{ path: '/', element: <div data-testid="home-page">Home</div> }],
})
}
function fillAndSubmit(currentPw: string, newPw: string, confirmPw: string) {
fireEvent.change(screen.getByTestId('current-password-input'), {
target: { value: currentPw },
})
fireEvent.change(screen.getByTestId('new-password-input'), {
target: { value: newPw },
})
fireEvent.change(screen.getByTestId('confirm-password-input'), {
target: { value: confirmPw },
})
fireEvent.submit(screen.getByTestId('change-password-form'))
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ChangePasswordPage', () => {
beforeEach(() => {
vi.clearAllMocks()
// Default: authenticated user with force_password_change=true
mockUseSession.mockReturnValue({
status: 'authenticated',
user: { username: 'admin', force_password_change: true },
})
})
it('renders the change-password form for a forced-change user', () => {
renderChangePw()
expect(screen.getByTestId('change-password-form')).toBeInTheDocument()
expect(screen.getByTestId('current-password-input')).toBeInTheDocument()
expect(screen.getByTestId('new-password-input')).toBeInTheDocument()
expect(screen.getByTestId('confirm-password-input')).toBeInTheDocument()
expect(screen.getByTestId('change-password-submit')).toBeInTheDocument()
})
it('navigates to "/" after a successful password change', async () => {
// Simulate successful POST /api/auth/password
mockPost.mockResolvedValueOnce({
data: {},
response: { status: 200, ok: true },
})
renderChangePw()
fillAndSubmit('old-password', 'new-password', 'new-password')
await waitFor(() => {
expect(screen.getByTestId('home-page')).toBeInTheDocument()
})
})
it('calls POST /api/auth/password with the correct body', async () => {
mockPost.mockResolvedValueOnce({
data: {},
response: { status: 200, ok: true },
})
renderChangePw()
fillAndSubmit('current123', 'newpass456', 'newpass456')
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith('/api/auth/password', {
body: {
current_password: 'current123',
new_password: 'newpass456',
confirm_password: 'newpass456',
},
})
})
})
it('shows error and does NOT call the API when new passwords do not match', async () => {
renderChangePw()
fillAndSubmit('current-pw', 'new-pw-1', 'new-pw-2')
await waitFor(() => {
expect(screen.getByTestId('change-password-error')).toBeInTheDocument()
})
expect(screen.getByTestId('change-password-error')).toHaveTextContent(
/do not match/i,
)
expect(mockPost).not.toHaveBeenCalled()
// Should remain on the form
expect(screen.getByTestId('change-password-form')).toBeInTheDocument()
})
it('shows generic error on API 400 and stays on form', async () => {
// Simulate 400 via ApiError throw (as the client middleware does)
const { ApiError } = await import('../api/client')
mockPost.mockRejectedValueOnce(new ApiError(400, { detail: 'wrong password' }))
renderChangePw()
fillAndSubmit('wrong-current', 'newpass', 'newpass')
await waitFor(() => {
expect(screen.getByTestId('change-password-error')).toBeInTheDocument()
})
expect(screen.getByTestId('change-password-error')).toHaveTextContent(
/password change failed/i,
)
// Should NOT have navigated away
expect(screen.getByTestId('change-password-form')).toBeInTheDocument()
})
it('redirects a non-forced user away from /change-password to "/"', async () => {
// A user who has already changed their password
mockUseSession.mockReturnValue({
status: 'authenticated',
user: { username: 'admin', force_password_change: false },
})
renderChangePw()
await waitFor(() => {
expect(screen.getByTestId('home-page')).toBeInTheDocument()
})
// The change-password form must NOT be shown
expect(screen.queryByTestId('change-password-form')).not.toBeInTheDocument()
})
})
+168
View File
@@ -0,0 +1,168 @@
/**
* ChangePasswordPage forced password change gate (M2-T07).
*
* Shown when the authenticated user has force_password_change === true.
* Blocks access to all other pages until the password is changed.
*
* Behaviours:
* - If the current user does NOT have force_password_change, redirect to '/'
* (mirrors LoginPage's already-authenticated guard).
* - POST /api/auth/password with { current_password, new_password, confirm_password }.
* - On ApiError 400 show a generic failure message (do not leak details).
* - On success invalidate ['session'] so SessionProvider re-fetches with
* force_password_change=false, then navigate to '/' to enter the app.
*/
import { useState } from 'react'
import { useNavigate, useLocation, Navigate } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import {
Container,
Paper,
Title,
Text,
PasswordInput,
Button,
Alert,
Stack,
Center,
} from '@mantine/core'
import { useSession } from '../auth/SessionProvider'
import apiClient from '../api/client'
import { ApiError } from '../api/client'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface LocationState {
from?: { pathname: string }
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function ChangePasswordPage() {
const { user } = useSession()
const navigate = useNavigate()
const location = useLocation()
const queryClient = useQueryClient()
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
// Guard: if the user is authenticated but NOT in forced-change state, redirect
// to the app. This prevents a non-forced user from sitting on /change-password.
// (Mirrors LoginPage's already-authenticated redirect.)
if (user && !user.force_password_change) {
const from = (location.state as LocationState)?.from?.pathname ?? '/'
return <Navigate to={from} replace />
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
// Client-side validation: confirm passwords match before hitting the server.
if (newPassword !== confirmPassword) {
setError('New passwords do not match.')
return
}
setLoading(true)
try {
await apiClient.POST('/api/auth/password', {
body: {
current_password: currentPassword,
new_password: newPassword,
confirm_password: confirmPassword,
},
})
// Success: refresh session so force_password_change becomes false,
// then navigate into the app — the guard above (and ProtectedRoute) will
// no longer block access once the session is updated.
await queryClient.invalidateQueries({ queryKey: ['session'] })
navigate('/', { replace: true })
} catch (err) {
if (err instanceof ApiError && err.status === 400) {
// Generic failure message — do not leak backend detail.
setError('Password change failed. Please check your current password and try again.')
} else {
setError('An unexpected error occurred. Please try again.')
}
} finally {
setLoading(false)
}
}
return (
<Center mih="100vh">
<Container size="xs" w="100%">
<Paper shadow="sm" p="xl" radius="md" withBorder>
<Title order={2} mb="xs" ta="center">
Change Password
</Title>
<Text c="dimmed" size="sm" mb="lg" ta="center">
You must change your password before continuing.
</Text>
{error && (
<Alert color="red" mb="md" role="alert" data-testid="change-password-error">
{error}
</Alert>
)}
<form onSubmit={handleSubmit} data-testid="change-password-form">
<Stack gap="md">
<PasswordInput
label="Current Password"
placeholder="Enter your current password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
required
autoComplete="current-password"
data-testid="current-password-input"
/>
<PasswordInput
label="New Password"
placeholder="Enter your new password"
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
required
autoComplete="new-password"
data-testid="new-password-input"
/>
<PasswordInput
label="Confirm New Password"
placeholder="Confirm your new password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
required
autoComplete="new-password"
data-testid="confirm-password-input"
/>
<Button
type="submit"
fullWidth
loading={loading}
mt="sm"
data-testid="change-password-submit"
>
Change Password
</Button>
</Stack>
</form>
</Paper>
</Container>
</Center>
)
}
+337
View File
@@ -0,0 +1,337 @@
/**
* Tests for ConfigPage (M2-T08).
*
* Strategy: vi.mock the apiClient module so we can control GET/PUT/POST responses
* without a real server.
*
* Coverage:
* 1. Renders config sections from a mocked GET /api/config response.
* 2. Secret fields start as empty (never display masked value).
* 3. Non-secret fields show their loaded values.
* 4. Save: updates map includes all non-secret fields and excludes untouched secrets.
* 5. Save: updates map includes a secret only when the user typed a new value.
* 6. Save success shows success notice.
* 7. Save error shows error notice.
* 8. SMTP test button: success state (200 result=success).
* 9. SMTP test button: config-error state (400/ApiError result=config-error).
* 10. SMTP test button: failed state (502/ApiError result=failed).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor, fireEvent } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
import { ConfigPage } from './ConfigPage'
// ---------------------------------------------------------------------------
// Fixture: config sections
// ---------------------------------------------------------------------------
const MOCK_CONFIG = {
sections: [
{
name: 'General',
fields: [
{ env_name: 'APP_NAME', label: 'App Name', value: 'My Home', secret: false, input_type: 'text', configured: true },
{ env_name: 'APP_PORT', label: 'Port', value: '8000', secret: false, input_type: 'number', configured: true },
],
},
{
name: 'SMTP',
fields: [
{ env_name: 'SMTP_HOST', label: 'SMTP Host', value: 'smtp.example.com', secret: false, input_type: 'text', configured: true },
{ env_name: 'SMTP_PASSWORD', label: 'SMTP Password', value: '', secret: true, input_type: 'password', configured: true },
],
},
],
}
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPut = vi.fn()
const mockPost = vi.fn()
vi.mock('../api/client', () => ({
default: {
GET: (...args: unknown[]) => mockGet(...args),
PUT: (...args: unknown[]) => mockPut(...args),
POST: (...args: unknown[]) => mockPost(...args),
},
ApiError: class ApiError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown) {
super(`API error ${status}`)
this.name = 'ApiError'
this.status = status
this.body = body
}
},
registerLoginRedirect: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderConfig() {
return renderWithProviders(<ConfigPage />, { initialPath: '/config' })
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ConfigPage', () => {
beforeEach(() => {
vi.clearAllMocks()
// Default: GET /api/config returns the fixture
mockGet.mockResolvedValue({ data: MOCK_CONFIG, response: { status: 200, ok: true } })
})
// -------------------------------------------------------------------------
// 1. Renders sections
// -------------------------------------------------------------------------
it('renders section names and field labels', async () => {
renderConfig()
await waitFor(() => {
expect(screen.getByText('General')).toBeInTheDocument()
})
expect(screen.getByText('SMTP')).toBeInTheDocument()
expect(screen.getByText('App Name')).toBeInTheDocument()
expect(screen.getByText('SMTP Host')).toBeInTheDocument()
expect(screen.getByText('SMTP Password')).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 2. Secret fields start empty
// -------------------------------------------------------------------------
it('renders secret fields with empty value (never displays masked value)', async () => {
renderConfig()
await waitFor(() => {
expect(screen.getByText('SMTP Password')).toBeInTheDocument()
})
// Mantine puts data-testid on the <input> element itself
const secretInput = screen.getByTestId('field-secret-SMTP_PASSWORD') as HTMLInputElement
expect(secretInput.value).toBe('')
})
// -------------------------------------------------------------------------
// 3. Non-secret fields show their loaded values
// -------------------------------------------------------------------------
it('renders non-secret fields with their loaded values', async () => {
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('field-APP_NAME')).toBeInTheDocument()
})
// Mantine puts data-testid on the <input> element itself for TextInput
const appNameInput = screen.getByTestId('field-APP_NAME') as HTMLInputElement
expect(appNameInput.value).toBe('My Home')
const smtpHostInput = screen.getByTestId('field-SMTP_HOST') as HTMLInputElement
expect(smtpHostInput.value).toBe('smtp.example.com')
})
// -------------------------------------------------------------------------
// 4. Save: updates includes all non-secrets, excludes untouched secrets
// -------------------------------------------------------------------------
it('save sends all non-secret fields and excludes untouched (blank) secrets', async () => {
mockPut.mockResolvedValueOnce({ data: {}, response: { status: 200, ok: true } })
// After save, refetch
mockGet.mockResolvedValue({ data: MOCK_CONFIG, response: { status: 200, ok: true } })
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('config-form')).toBeInTheDocument()
})
// Submit without touching any field
fireEvent.submit(screen.getByTestId('config-form'))
await waitFor(() => {
expect(mockPut).toHaveBeenCalled()
})
const putCall = mockPut.mock.calls[0]
const body = putCall[1].body as { updates: Record<string, string> }
const updates = body.updates
// Non-secret fields MUST be present
expect(updates).toHaveProperty('APP_NAME', 'My Home')
expect(updates).toHaveProperty('APP_PORT', '8000')
expect(updates).toHaveProperty('SMTP_HOST', 'smtp.example.com')
// Untouched secret field MUST NOT be present
expect(updates).not.toHaveProperty('SMTP_PASSWORD')
})
// -------------------------------------------------------------------------
// 5. Save: updates includes secret when user typed a new value
// -------------------------------------------------------------------------
it('save includes a secret field when the user typed a new value', async () => {
mockPut.mockResolvedValueOnce({ data: {}, response: { status: 200, ok: true } })
mockGet.mockResolvedValue({ data: MOCK_CONFIG, response: { status: 200, ok: true } })
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('field-secret-SMTP_PASSWORD')).toBeInTheDocument()
})
// Mantine puts data-testid on the <input> element itself
const secretInput = screen.getByTestId('field-secret-SMTP_PASSWORD') as HTMLInputElement
fireEvent.change(secretInput, { target: { value: 'new-secret-value' } })
fireEvent.submit(screen.getByTestId('config-form'))
await waitFor(() => {
expect(mockPut).toHaveBeenCalled()
})
const putCall = mockPut.mock.calls[0]
const body = putCall[1].body as { updates: Record<string, string> }
const updates = body.updates
// Secret MUST be included because the user typed a value
expect(updates).toHaveProperty('SMTP_PASSWORD', 'new-secret-value')
// Non-secrets still present
expect(updates).toHaveProperty('APP_NAME', 'My Home')
})
// -------------------------------------------------------------------------
// 6. Save success → shows success notice
// -------------------------------------------------------------------------
it('shows success alert after a successful save', async () => {
mockPut.mockResolvedValueOnce({ data: {}, response: { status: 200, ok: true } })
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('config-form')).toBeInTheDocument()
})
fireEvent.submit(screen.getByTestId('config-form'))
await waitFor(() => {
expect(screen.getByTestId('save-success')).toBeInTheDocument()
})
expect(screen.queryByTestId('save-error')).not.toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 7. Save error → shows error notice
// -------------------------------------------------------------------------
it('shows error alert when save fails', async () => {
const { ApiError } = await import('../api/client')
mockPut.mockRejectedValueOnce(new ApiError(422, { detail: 'invalid value' }))
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('config-form')).toBeInTheDocument()
})
fireEvent.submit(screen.getByTestId('config-form'))
await waitFor(() => {
expect(screen.getByTestId('save-error')).toBeInTheDocument()
})
expect(screen.queryByTestId('save-success')).not.toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 8. SMTP test button: success state
// -------------------------------------------------------------------------
it('shows success alert after SMTP test succeeds', async () => {
mockPost.mockResolvedValueOnce({
data: { result: 'success', message: 'Email delivered.' },
response: { status: 200, ok: true },
})
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('smtp-test-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('smtp-test-button'))
await waitFor(() => {
expect(screen.getByTestId('smtp-result-success')).toBeInTheDocument()
})
expect(screen.queryByTestId('smtp-result-config-error')).not.toBeInTheDocument()
expect(screen.queryByTestId('smtp-result-failed')).not.toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 9. SMTP test button: config-error state (400)
// -------------------------------------------------------------------------
it('shows config-error alert when SMTP test returns config-error', async () => {
const { ApiError } = await import('../api/client')
mockPost.mockRejectedValueOnce(
new ApiError(400, { result: 'config-error', message: 'SMTP host not configured.' }),
)
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('smtp-test-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('smtp-test-button'))
await waitFor(() => {
expect(screen.getByTestId('smtp-result-config-error')).toBeInTheDocument()
})
expect(screen.queryByTestId('smtp-result-success')).not.toBeInTheDocument()
expect(screen.queryByTestId('smtp-result-failed')).not.toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 10. SMTP test button: failed state (502)
// -------------------------------------------------------------------------
it('shows failed alert when SMTP test returns failed', async () => {
const { ApiError } = await import('../api/client')
mockPost.mockRejectedValueOnce(
new ApiError(502, { result: 'failed', message: 'Connection refused.' }),
)
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('smtp-test-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('smtp-test-button'))
await waitFor(() => {
expect(screen.getByTestId('smtp-result-failed')).toBeInTheDocument()
})
expect(screen.queryByTestId('smtp-result-success')).not.toBeInTheDocument()
expect(screen.queryByTestId('smtp-result-config-error')).not.toBeInTheDocument()
})
})
+404
View File
@@ -0,0 +1,404 @@
/**
* ConfigPage config editor (M2-T08).
*
* Behaviours:
* 1. Load config: GET /api/config render sections (grouped) with Mantine inputs.
* - Non-secret fields show their value.
* - Secret fields render as empty PasswordInput (never show a masked value).
* 2. Save config: PUT /api/config with full-field submission semantics.
* - All non-secret fields are ALWAYS included (to avoid backend zeroing absent fields).
* - Secret fields are included ONLY when the user typed a new (non-empty) value.
* - On success: show a success notice and refetch config.
* - On ApiError 422: show an error notice, nothing was written.
* 3. SMTP test button: POST /api/config/smtp/test.
* - Tri-state: success / config-error / failed.
* - Errors read `err.body.result` from ApiError.
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Container,
Title,
Text,
TextInput,
PasswordInput,
Button,
Alert,
Stack,
Group,
Divider,
Loader,
Center,
Paper,
Badge,
} from '@mantine/core'
import apiClient, { ApiError } from '../api/client'
import type { components } from '../api/schema.d.ts'
import { TotpSettings } from './TotpSettings'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ConfigField = components['schemas']['ConfigField']
type ConfigSection = components['schemas']['ConfigSection']
/** SMTP test result tri-state. */
type SmtpResult =
| { kind: 'success'; message: string }
| { kind: 'config-error'; message: string }
| { kind: 'failed'; message: string }
| null
// ---------------------------------------------------------------------------
// Hook: load config
// ---------------------------------------------------------------------------
function useConfig() {
return useQuery({
queryKey: ['config'],
queryFn: async () => {
const res = await apiClient.GET('/api/config')
return res.data
},
})
}
// ---------------------------------------------------------------------------
// Helper: build updates map for PUT /api/config
//
// Full-field submission semantics (§6):
// - Non-secret fields: ALWAYS include current value (even if unchanged) so
// the backend does not zero out absent fields.
// - Secret fields: include ONLY when the user typed a non-empty value.
// Blank secret = keep old value; sending blank would also keep it per
// backend semantics, but we omit it to be explicit and avoid confusion.
// ---------------------------------------------------------------------------
function buildUpdates(
sections: ConfigSection[],
localValues: Record<string, string>,
): Record<string, string> {
const updates: Record<string, string> = {}
for (const section of sections) {
for (const field of section.fields) {
const localVal = localValues[field.env_name] ?? ''
if (field.secret) {
// Only include secret if the user typed something (non-empty).
if (localVal !== '') {
updates[field.env_name] = localVal
}
// blank secret → omit → backend keeps the existing stored value
} else {
// Non-secret: always include current local value.
updates[field.env_name] = localVal
}
}
}
return updates
}
// ---------------------------------------------------------------------------
// ConfigFieldInput — renders a single config field
// ---------------------------------------------------------------------------
interface ConfigFieldInputProps {
field: ConfigField
value: string
onChange: (envName: string, value: string) => void
}
function ConfigFieldInput({ field, value, onChange }: ConfigFieldInputProps) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(field.env_name, e.currentTarget.value)
}
if (field.secret) {
return (
<PasswordInput
label={field.label}
placeholder={field.configured ? '(configured — leave blank to keep)' : 'Enter value'}
value={value}
onChange={handleChange}
data-testid={`field-secret-${field.env_name}`}
autoComplete="off"
/>
)
}
if (field.input_type === 'number') {
return (
<TextInput
label={field.label}
type="number"
value={value}
onChange={handleChange}
data-testid={`field-${field.env_name}`}
/>
)
}
return (
<TextInput
label={field.label}
type={field.input_type === 'email' ? 'email' : 'text'}
value={value}
onChange={handleChange}
data-testid={`field-${field.env_name}`}
/>
)
}
// ---------------------------------------------------------------------------
// ConfigSectionPanel — one section
// ---------------------------------------------------------------------------
interface ConfigSectionPanelProps {
section: ConfigSection
localValues: Record<string, string>
onChange: (envName: string, value: string) => void
}
function ConfigSectionPanel({ section, localValues, onChange }: ConfigSectionPanelProps) {
return (
<Paper withBorder p="md" radius="md">
<Title order={4} mb="md">
{section.name}
</Title>
<Stack gap="sm">
{section.fields.map((field) => (
<ConfigFieldInput
key={field.env_name}
field={field}
value={localValues[field.env_name] ?? ''}
onChange={onChange}
/>
))}
</Stack>
</Paper>
)
}
// ---------------------------------------------------------------------------
// SmtpTestButton — sends POST /api/config/smtp/test and displays tri-state result
// ---------------------------------------------------------------------------
interface SmtpTestButtonProps {
smtpResult: SmtpResult
setSmtpResult: (r: SmtpResult) => void
}
function SmtpTestButton({ smtpResult, setSmtpResult }: SmtpTestButtonProps) {
const [testing, setTesting] = useState(false)
async function handleTest() {
setSmtpResult(null)
setTesting(true)
try {
const res = await apiClient.POST('/api/config/smtp/test')
if (res.data) {
setSmtpResult({ kind: 'success', message: res.data.message })
}
} catch (err) {
if (err instanceof ApiError) {
const body = err.body as { result?: string; message?: string } | null
const result = body?.result
const message = body?.message ?? 'Unknown error'
if (result === 'config-error') {
setSmtpResult({ kind: 'config-error', message })
} else {
// result === 'failed' or any other error
setSmtpResult({ kind: 'failed', message })
}
} else {
setSmtpResult({ kind: 'failed', message: 'Unexpected error sending test email.' })
}
} finally {
setTesting(false)
}
}
return (
<Stack gap="xs">
<Button
variant="outline"
onClick={handleTest}
loading={testing}
data-testid="smtp-test-button"
>
Send Test Email
</Button>
{smtpResult?.kind === 'success' && (
<Alert color="green" data-testid="smtp-result-success">
Test email sent successfully. {smtpResult.message}
</Alert>
)}
{smtpResult?.kind === 'config-error' && (
<Alert color="orange" data-testid="smtp-result-config-error">
SMTP configuration error check your SMTP settings. {smtpResult.message}
</Alert>
)}
{smtpResult?.kind === 'failed' && (
<Alert color="red" data-testid="smtp-result-failed">
Test email send failed. {smtpResult.message}
</Alert>
)}
</Stack>
)
}
// ---------------------------------------------------------------------------
// ConfigPage — main component
// ---------------------------------------------------------------------------
export function ConfigPage() {
const queryClient = useQueryClient()
const { data, isLoading, isError } = useConfig()
// Local field values — mirrors the loaded config but allows user edits.
// Secret fields always start as empty string (never display masked values).
const [localValues, setLocalValues] = useState<Record<string, string>>({})
const [valuesInitialized, setValuesInitialized] = useState(false)
// Initialise local state once when data arrives (or re-arrives after refetch).
if (data && !valuesInitialized) {
const initial: Record<string, string> = {}
for (const section of data.sections) {
for (const field of section.fields) {
// Secret fields start empty (never display the masked/empty backend value).
initial[field.env_name] = field.secret ? '' : (field.value ?? '')
}
}
setLocalValues(initial)
setValuesInitialized(true)
}
// Save notice state
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null)
// SMTP test tri-state
const [smtpResult, setSmtpResult] = useState<SmtpResult>(null)
function handleChange(envName: string, value: string) {
setLocalValues((prev) => ({ ...prev, [envName]: value }))
setSaveStatus(null)
}
const saveMutation = useMutation({
mutationFn: async () => {
if (!data) return
const updates = buildUpdates(data.sections, localValues)
await apiClient.PUT('/api/config', { body: { updates } })
},
})
async function handleSave(e: React.FormEvent) {
e.preventDefault()
setSaveStatus(null)
try {
await saveMutation.mutateAsync()
setSaveStatus('success')
// Refetch config so the page reflects the saved state.
await queryClient.invalidateQueries({ queryKey: ['config'] })
// After refetch, reset initialised flag so local state rebuilds from fresh data.
setValuesInitialized(false)
} catch {
setSaveStatus('error')
}
}
// ---------------------------------------------------------------------------
// Render states
// ---------------------------------------------------------------------------
if (isLoading) {
return (
<Center pt="xl">
<Loader data-testid="config-loading" />
</Center>
)
}
if (isError || !data) {
return (
<Container pt="xl">
<Alert color="red" data-testid="config-load-error">
Failed to load configuration. Please refresh the page.
</Alert>
</Container>
)
}
// Detect if there is an SMTP section (to show the test button).
const hasSmtpSection = data.sections.some((s) =>
s.name.toLowerCase().includes('smtp') || s.name.toLowerCase().includes('email'),
)
return (
<Container size="md" pt="xl" pb="xl" data-testid="config-page">
<Group justify="space-between" mb="lg" wrap="nowrap">
<Title order={2}>Configuration</Title>
<Badge variant="outline" color="gray" size="sm">
{data.sections.length} section{data.sections.length !== 1 ? 's' : ''}
</Badge>
</Group>
<form onSubmit={handleSave} data-testid="config-form">
<Stack gap="lg">
{data.sections.map((section) => (
<ConfigSectionPanel
key={section.name}
section={section}
localValues={localValues}
onChange={handleChange}
/>
))}
<Divider />
{saveStatus === 'success' && (
<Alert color="green" data-testid="save-success">
Configuration saved successfully.
</Alert>
)}
{saveStatus === 'error' && (
<Alert color="red" data-testid="save-error">
Failed to save configuration. Please check the values and try again.
</Alert>
)}
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
<Button
type="submit"
loading={saveMutation.isPending}
data-testid="config-save-button"
>
Save Configuration
</Button>
{hasSmtpSection && (
<SmtpTestButton smtpResult={smtpResult} setSmtpResult={setSmtpResult} />
)}
</Group>
</Stack>
</form>
{!hasSmtpSection && (
<Stack mt="md">
<Text c="dimmed" size="sm">
Configure SMTP settings to enable email notifications.
</Text>
</Stack>
)}
{/* TOTP two-factor auth management */}
<Stack mt="xl">
<TotpSettings />
</Stack>
</Container>
)
}
+274
View File
@@ -0,0 +1,274 @@
/**
* HomePage tests M2-T09.
*
* Leaflet is mocked so jsdom doesn't choke on DOM APIs it doesn't support.
* We verify:
* 1. Controls render (time range inputs, layer toggles, apply button).
* 2. Point-select: when onSelectLocation is called, EditLocationModal opens.
* 3. Point-select: when onSelectPoo is called, EditPooModal opens.
* 4. The map component is rendered (mocked).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { MantineProvider } from '@mantine/core'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import type { ReactNode } from 'react'
// ---------------------------------------------------------------------------
// Mock leaflet / react-leaflet before any component imports them.
// ---------------------------------------------------------------------------
vi.mock('leaflet', () => ({
default: {},
Icon: { Default: { prototype: {}, mergeOptions: vi.fn() } },
DivIcon: vi.fn(() => ({})),
heatLayer: vi.fn(() => ({ setLatLngs: vi.fn(), addTo: vi.fn() })),
markerClusterGroup: vi.fn(() => ({ addLayer: vi.fn(), clearLayers: vi.fn() })),
marker: vi.fn(() => ({
bindTooltip: vi.fn().mockReturnThis(),
on: vi.fn().mockReturnThis(),
})),
tileLayer: vi.fn(),
map: vi.fn(),
}))
vi.mock('leaflet.heat', () => ({}))
vi.mock('leaflet.markercluster', () => ({}))
vi.mock('react-leaflet', () => ({
MapContainer: ({ children }: { children: ReactNode }) => (
<div data-testid="records-map">{children}</div>
),
TileLayer: () => null,
useMap: () => ({
addLayer: vi.fn(),
removeLayer: vi.fn(),
hasLayer: vi.fn(() => false),
}),
}))
// Mock leaflet image imports
vi.mock('leaflet/dist/images/marker-icon-2x.png', () => ({ default: 'marker-icon-2x.png' }))
vi.mock('leaflet/dist/images/marker-icon.png', () => ({ default: 'marker-icon.png' }))
vi.mock('leaflet/dist/images/marker-shadow.png', () => ({ default: 'marker-shadow.png' }))
// Mock leaflet CSS
vi.mock('leaflet/dist/leaflet.css', () => ({}))
vi.mock('leaflet.markercluster/dist/MarkerCluster.css', () => ({}))
vi.mock('leaflet.markercluster/dist/MarkerCluster.Default.css', () => ({}))
// ---------------------------------------------------------------------------
// Mock RecordsMap to capture onSelectLocation / onSelectPoo callbacks
// ---------------------------------------------------------------------------
import type { RecordsMapProps } from '../map/RecordsMap'
let capturedOnSelectLocation: RecordsMapProps['onSelectLocation'] | undefined
let capturedOnSelectPoo: RecordsMapProps['onSelectPoo'] | undefined
vi.mock('../map/RecordsMap', () => ({
RecordsMap: (props: RecordsMapProps) => {
capturedOnSelectLocation = props.onSelectLocation
capturedOnSelectPoo = props.onSelectPoo
return <div data-testid="records-map-mock" />
},
}))
// ---------------------------------------------------------------------------
// Mock apiClient — return minimal data so queries resolve
// ---------------------------------------------------------------------------
vi.mock('../api/client', () => ({
default: {
GET: vi.fn(async (path: string) => {
if (path === '/api/locations') {
return {
data: {
items: [
{
person: 'alice',
datetime: '2026-01-15T10:00:00Z',
latitude: 39.9,
longitude: 116.4,
altitude: null,
},
],
limit: 5000,
offset: 0,
},
}
}
if (path === '/api/poo') {
return {
data: {
items: [
{
timestamp: '2026-01-20T09:00:00Z',
status: 'done',
latitude: 39.91,
longitude: 116.41,
},
],
limit: 1000,
offset: 0,
},
}
}
return { data: null }
}),
},
}))
// ---------------------------------------------------------------------------
// Now import components under test (after mocks are registered)
// ---------------------------------------------------------------------------
import { HomePage } from './HomePage'
// ---------------------------------------------------------------------------
// Test wrapper
// ---------------------------------------------------------------------------
function makeQC() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
function Wrapper({ qc, children }: { qc: QueryClient; children: ReactNode }) {
return (
<MantineProvider>
<QueryClientProvider client={qc}>
<MemoryRouter>{children}</MemoryRouter>
</QueryClientProvider>
</MantineProvider>
)
}
// Helper: render HomePage and wait for queries to resolve
async function renderHomePage() {
const qc = makeQC()
const utils = render(
<Wrapper qc={qc}>
<HomePage />
</Wrapper>,
)
// Wait for the map mock to appear (data loaded)
await waitFor(() => screen.getByTestId('records-map-mock'))
return utils
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('HomePage', () => {
beforeEach(() => {
capturedOnSelectLocation = undefined
capturedOnSelectPoo = undefined
})
it('renders time-range controls', async () => {
await renderHomePage()
expect(screen.getByTestId('time-start-input')).toBeTruthy()
expect(screen.getByTestId('time-end-input')).toBeTruthy()
expect(screen.getByTestId('apply-window-button')).toBeTruthy()
})
it('renders layer toggle switches', async () => {
await renderHomePage()
expect(screen.getByTestId('toggle-location-heat')).toBeTruthy()
expect(screen.getByTestId('toggle-poo-heat')).toBeTruthy()
expect(screen.getByTestId('toggle-scatter')).toBeTruthy()
})
it('renders the RecordsMap component', async () => {
await renderHomePage()
expect(screen.getByTestId('records-map-mock')).toBeTruthy()
})
it('opens EditLocationModal when onSelectLocation is called with a location record', async () => {
await renderHomePage()
// Simulate clicking a location scatter point
const record = {
person: 'alice',
datetime: '2026-01-15T10:00:00Z',
latitude: 39.9,
longitude: 116.4,
altitude: null,
}
expect(capturedOnSelectLocation).toBeDefined()
capturedOnSelectLocation!(record)
// EditLocationModal should appear
await waitFor(() => screen.getByTestId('edit-location-modal'))
expect(screen.getByTestId('edit-location-modal')).toBeTruthy()
})
it('opens EditPooModal when onSelectPoo is called with a poo record', async () => {
await renderHomePage()
const record = {
timestamp: '2026-01-20T09:00:00Z',
status: 'done',
latitude: 39.91,
longitude: 116.41,
}
expect(capturedOnSelectPoo).toBeDefined()
capturedOnSelectPoo!(record)
await waitFor(() => screen.getByTestId('edit-poo-modal'))
expect(screen.getByTestId('edit-poo-modal')).toBeTruthy()
})
it('closes EditLocationModal when Cancel is clicked', async () => {
await renderHomePage()
const record = {
person: 'alice',
datetime: '2026-01-15T10:00:00Z',
latitude: 39.9,
longitude: 116.4,
altitude: null,
}
capturedOnSelectLocation!(record)
await waitFor(() => screen.getByTestId('edit-location-modal'))
fireEvent.click(screen.getByTestId('edit-location-cancel'))
await waitFor(() => expect(screen.queryByTestId('edit-location-modal')).toBeNull())
})
it('closes EditPooModal when Cancel is clicked', async () => {
await renderHomePage()
const record = {
timestamp: '2026-01-20T09:00:00Z',
status: 'done',
latitude: 39.91,
longitude: 116.41,
}
capturedOnSelectPoo!(record)
await waitFor(() => screen.getByTestId('edit-poo-modal'))
fireEvent.click(screen.getByTestId('edit-poo-cancel'))
await waitFor(() => expect(screen.queryByTestId('edit-poo-modal')).toBeNull())
})
it('time-range inputs have default values', async () => {
await renderHomePage()
const startInput = screen.getByTestId('time-start-input') as HTMLInputElement
const endInput = screen.getByTestId('time-end-input') as HTMLInputElement
expect(startInput.value).toBeTruthy()
expect(endInput.value).toBeTruthy()
})
it('Apply button re-triggers data fetch with new window', async () => {
await renderHomePage()
const startInput = screen.getByTestId('time-start-input') as HTMLInputElement
fireEvent.change(startInput, { target: { value: '2026-01-01T00:00' } })
fireEvent.click(screen.getByTestId('apply-window-button'))
// Just verify no crash; data refresh happens async via React Query.
await waitFor(() => screen.getByTestId('records-map-mock'))
})
})
+406
View File
@@ -0,0 +1,406 @@
/**
* HomePage data-visualization map view (M2-T09).
*
* Renders a heat map of location records (where you've been) and poo records
* (where the dog poops), plus a toggleable scatter layer for point-select
* edit/delete (reusing T10's modals + hooks).
*
* Data fetching and all state live here; the map itself is fully isolated in
* src/map/RecordsMap.tsx (the ONLY place that imports leaflet).
*/
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
Stack,
Group,
Switch,
TextInput,
Button,
Select,
ActionIcon,
Tooltip,
Paper,
Text,
Box,
Loader,
Alert,
Badge,
useComputedColorScheme,
} from '@mantine/core'
import { ChevronLeft, ChevronRight } from 'react-feather'
import apiClient from '../api/client'
import {
locationsToHeatPoints,
pooToHeatPoints,
locationsToMapPoints,
pooToMapPoints,
filterPooByTimeWindow,
daysAgoISO,
nowISO,
TIME_PRESETS,
presetRange,
shiftRange,
} from '../map'
import { RecordsMap } from '../map'
import {
EditLocationModal,
EditPooModal,
ConfirmDeleteModal,
useDeleteLocation,
useDeletePoo,
} from '../records'
import type { LocationRecord, PooRecord } from '../records'
// ---------------------------------------------------------------------------
// Data hooks (query-key prefix: ['locations', ...] / ['poo', ...])
// ---------------------------------------------------------------------------
function useLocations(start: string | null, end: string | null) {
return useQuery({
queryKey: ['locations', { start, end, limit: 5000 }],
queryFn: async () => {
const res = await apiClient.GET('/api/locations', {
params: {
query: {
limit: 5000,
offset: 0,
...(start ? { start } : {}),
...(end ? { end } : {}),
},
},
})
return res.data?.items ?? []
},
})
}
/**
* Poo endpoint has no server-side time filter fetch a large page (max 1000)
* and client-filter by timestamp below.
*/
function usePoo() {
return useQuery({
queryKey: ['poo', { limit: 1000 }],
queryFn: async () => {
const res = await apiClient.GET('/api/poo', {
params: { query: { limit: 1000, offset: 0 } },
})
return res.data?.items ?? []
},
})
}
// ---------------------------------------------------------------------------
// Point-select state (which record is selected + which modal to show)
// ---------------------------------------------------------------------------
type SelectionState =
| { kind: 'none' }
| { kind: 'editLocation'; record: LocationRecord }
| { kind: 'deleteLocation'; record: LocationRecord }
| { kind: 'editPoo'; record: PooRecord }
| { kind: 'deletePoo'; record: PooRecord }
// ---------------------------------------------------------------------------
// HomePage
// ---------------------------------------------------------------------------
export function HomePage() {
// ------ Time-window state -----------------------------------------------
// Default: last 30 days → now
const [startInput, setStartInput] = useState(() => {
const d = new Date()
d.setUTCDate(d.getUTCDate() - 30)
return d.toISOString().slice(0, 16) // "YYYY-MM-DDTHH:MM"
})
const [endInput, setEndInput] = useState(() => nowISO().slice(0, 16))
// Applied (committed) window — updated on Apply / preset / shift
const [appliedStart, setAppliedStart] = useState<string | null>(() => daysAgoISO(30))
const [appliedEnd, setAppliedEnd] = useState<string | null>(() => nowISO())
// Which quick-range preset is currently active (null = custom / shifted range)
const [activePreset, setActivePreset] = useState<string | null>(null)
// Set both the committed window and the editable inputs from an ISO [start, end].
function setWindow(startISO: string, endISO: string) {
setAppliedStart(startISO)
setAppliedEnd(endISO)
setStartInput(startISO.slice(0, 16))
setEndInput(endISO.slice(0, 16))
}
// Pick a quick range: fill from-to ending at now, apply immediately (Grafana-style).
function applyPreset(value: string | null) {
const preset = TIME_PRESETS.find((p) => p.value === value)
if (!preset) return
const { start, end } = presetRange(preset.spanMs)
setWindow(start, end)
setActivePreset(value)
}
// Shift the committed window by its own span. -1 = earlier, +1 = later.
function shiftWindow(direction: -1 | 1) {
if (!appliedStart || !appliedEnd) return
const { start, end } = shiftRange(appliedStart, appliedEnd, direction)
setWindow(start, end)
// A shifted window is an absolute range, no longer "now - X".
setActivePreset(null)
}
// ------ Layer toggle state -----------------------------------------------
const [showLocationHeat, setShowLocationHeat] = useState(true)
const [showPooHeat, setShowPooHeat] = useState(true)
const [showScatter, setShowScatter] = useState(false)
// ------ Data fetching ----------------------------------------------------
const locationsQuery = useLocations(appliedStart, appliedEnd)
const pooQuery = usePoo()
// Client-side time-filter for poo (server has no filter)
const filteredPoo = useMemo(
() => filterPooByTimeWindow(pooQuery.data ?? [], appliedStart, appliedEnd),
[pooQuery.data, appliedStart, appliedEnd],
)
// Derived map data
const locationHeatPoints = useMemo(
() => locationsToHeatPoints(locationsQuery.data ?? []),
[locationsQuery.data],
)
const pooHeatPoints = useMemo(
() => pooToHeatPoints(filteredPoo),
[filteredPoo],
)
const locationScatterPoints = useMemo(
() => locationsToMapPoints(locationsQuery.data ?? []),
[locationsQuery.data],
)
const pooScatterPoints = useMemo(
() => pooToMapPoints(filteredPoo),
[filteredPoo],
)
// ------ Point-select state -----------------------------------------------
const [selection, setSelection] = useState<SelectionState>({ kind: 'none' })
const deleteLocationMut = useDeleteLocation()
const deletePooMut = useDeletePoo()
// Handlers
function handleSelectLocation(record: LocationRecord) {
setSelection({ kind: 'editLocation', record })
}
function handleSelectPoo(record: PooRecord) {
setSelection({ kind: 'editPoo', record })
}
function applyWindow() {
// Convert local datetime-local inputs (which have no TZ) to ISO8601
// by appending :00Z if needed. Input is "YYYY-MM-DDTHH:MM".
const toISO = (s: string) => (s ? s + ':00Z' : null)
setAppliedStart(toISO(startInput))
setAppliedEnd(toISO(endInput))
// Manually-applied range is custom, not a preset.
setActivePreset(null)
}
// ------ Render -----------------------------------------------------------
const isLoading = locationsQuery.isLoading || pooQuery.isLoading
const isError = locationsQuery.isError || pooQuery.isError
const colorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
return (
<Box style={{ height: 'calc(100vh - 52px)', display: 'flex', flexDirection: 'column' }}>
{/* Controls bar */}
<Paper
shadow="xs"
p="xs"
style={{ zIndex: 1000, flexShrink: 0 }}
data-testid="map-controls"
>
<Stack gap="xs">
{/* Time-range row */}
<Group gap="xs" align="flex-end" wrap="wrap">
<TextInput
label="From"
type="datetime-local"
value={startInput}
onChange={(e) => setStartInput(e.currentTarget.value)}
size="xs"
style={{ minWidth: 180 }}
data-testid="time-start-input"
/>
<TextInput
label="To"
type="datetime-local"
value={endInput}
onChange={(e) => setEndInput(e.currentTarget.value)}
size="xs"
style={{ minWidth: 180 }}
data-testid="time-end-input"
/>
{/* Quick range + shift buttons (Grafana-style) between To and Apply.
zIndex raised above Leaflet (~1000) so the dropdown/tooltips are
not painted over by the map below. */}
<Group gap={4} align="flex-end">
<Select
label="Quick range"
placeholder="Pick a range"
data={TIME_PRESETS.map((p) => ({ value: p.value, label: p.label }))}
value={activePreset}
onChange={applyPreset}
size="xs"
allowDeselect={false}
style={{ width: 150 }}
comboboxProps={{ zIndex: 3000 }}
data-testid="quick-range-select"
/>
<Tooltip label="Shift earlier (one window back)" zIndex={3000}>
<ActionIcon
variant="default"
size="input-xs"
aria-label="Shift earlier"
onClick={() => shiftWindow(-1)}
data-testid="shift-earlier"
>
<ChevronLeft size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Shift later (one window forward)" zIndex={3000}>
<ActionIcon
variant="default"
size="input-xs"
aria-label="Shift later"
onClick={() => shiftWindow(1)}
data-testid="shift-later"
>
<ChevronRight size={16} />
</ActionIcon>
</Tooltip>
</Group>
<Button size="xs" onClick={applyWindow} data-testid="apply-window-button">
Apply
</Button>
{isLoading && <Loader size="xs" />}
</Group>
{/* Layer toggles row */}
<Group gap="md" wrap="wrap">
<Switch
label={
<Group gap={4}>
<Text size="xs">Location heat</Text>
<Badge size="xs" color="blue" variant="light">
{locationsQuery.data?.length ?? 0}
</Badge>
</Group>
}
checked={showLocationHeat}
onChange={(e) => setShowLocationHeat(e.currentTarget.checked)}
size="xs"
data-testid="toggle-location-heat"
/>
<Switch
label={
<Group gap={4}>
<Text size="xs">Poo heat</Text>
<Badge size="xs" color="orange" variant="light">
{filteredPoo.length}
</Badge>
</Group>
}
checked={showPooHeat}
onChange={(e) => setShowPooHeat(e.currentTarget.checked)}
size="xs"
data-testid="toggle-poo-heat"
/>
<Switch
label={<Text size="xs">Scatter (click to edit)</Text>}
checked={showScatter}
onChange={(e) => setShowScatter(e.currentTarget.checked)}
size="xs"
data-testid="toggle-scatter"
/>
</Group>
{/* Error banner */}
{isError && (
<Alert color="red" data-testid="map-error-alert">
Failed to load data. Check connection and refresh.
</Alert>
)}
</Stack>
</Paper>
{/* Map fills remaining height. `isolation: isolate` traps Leaflet's internal
z-indexes (panes/controls up to ~1000) in their own stacking context so
they can't paint over portaled popups (Quick-range dropdown, tooltips,
and the point-select edit/delete modals). */}
<Box style={{ flex: 1, minHeight: 0, isolation: 'isolate' }}>
<RecordsMap
locationHeatPoints={locationHeatPoints}
pooHeatPoints={pooHeatPoints}
locationScatterPoints={locationScatterPoints}
pooScatterPoints={pooScatterPoints}
showLocationHeat={showLocationHeat}
showPooHeat={showPooHeat}
showScatter={showScatter}
onSelectLocation={handleSelectLocation}
onSelectPoo={handleSelectPoo}
height="100%"
dark={colorScheme === 'dark'}
/>
</Box>
{/* ---------- Point-select modals ---------- */}
{selection.kind === 'editLocation' && (
<EditLocationModal
record={selection.record}
onClose={() => setSelection({ kind: 'none' })}
onSaved={() => setSelection({ kind: 'none' })}
/>
)}
{selection.kind === 'deleteLocation' && (
<ConfirmDeleteModal
message={`Delete location record for ${selection.record.person} at ${selection.record.datetime}?`}
loading={deleteLocationMut.isPending}
onConfirm={async () => {
await deleteLocationMut.mutateAsync({
person: selection.record.person,
datetime: selection.record.datetime,
})
setSelection({ kind: 'none' })
}}
onCancel={() => setSelection({ kind: 'none' })}
/>
)}
{selection.kind === 'editPoo' && (
<EditPooModal
record={selection.record}
onClose={() => setSelection({ kind: 'none' })}
onSaved={() => {
// After saving, optionally switch to delete prompt or just close.
setSelection({ kind: 'none' })
}}
/>
)}
{selection.kind === 'deletePoo' && (
<ConfirmDeleteModal
message={`Delete poo record at ${selection.record.timestamp}?`}
loading={deletePooMut.isPending}
onConfirm={async () => {
await deletePooMut.mutateAsync(selection.record.timestamp)
setSelection({ kind: 'none' })
}}
onCancel={() => setSelection({ kind: 'none' })}
/>
)}
</Box>
)
}
+364
View File
@@ -0,0 +1,364 @@
/**
* Tests for LoginPage (M2-T07, extended M4-T08).
*
* Strategy: vi.mock the apiClient module so we can control POST /api/auth/login
* responses without a real server. We also mock useSession so tests can control
* the authentication state.
*
* Coverage:
* 1. Renders the login form (step 1).
* 2. Successful login (step 1) invalidates session query + navigates.
* 3. 401 bad credentials shows inline error, does not navigate.
* 4. Already-authenticated users visiting /login redirected to '/'.
* 5. Unexpected error shows generic error message.
* 6. (M4-T08) 401 with totp_required switches to step 2 TOTP form.
* 7. (M4-T08) Step 2 submit with correct code success + navigate.
* 8. (M4-T08) Step 2 submit with wrong code shows error, stays on step 2.
* 9. (M4-T08) Step 2 "Back to login" button returns to step 1.
* 10. (M4-T08) 429 response shows "too many attempts" message.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor, fireEvent } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
import { LoginPage } from './LoginPage'
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
// We mock the entire api/client module. Each test can override POST as needed.
const mockPost = vi.fn()
vi.mock('../api/client', () => ({
default: {
POST: (...args: unknown[]) => mockPost(...args),
GET: vi.fn(),
},
ApiError: class ApiError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown) {
super(`API error ${status}`)
this.name = 'ApiError'
this.status = status
this.body = body
}
},
registerLoginRedirect: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Mock useSession — default: unauthenticated
// ---------------------------------------------------------------------------
// Typed as returning the wider union so mockReturnValue accepts all status variants.
const mockUseSession = vi.fn(() => ({
status: 'unauthenticated' as 'loading' | 'authenticated' | 'unauthenticated',
user: null as null | { username: string; force_password_change: boolean },
}))
vi.mock('../auth/SessionProvider', () => ({
useSession: () => mockUseSession(),
SessionProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderLogin(initialPath = '/login') {
return renderWithProviders(<LoginPage />, {
initialPath,
routes: [{ path: '/', element: <div data-testid="home-page">Home</div> }],
})
}
function fillAndSubmit(username: string, password: string) {
fireEvent.change(screen.getByTestId('username-input'), { target: { value: username } })
fireEvent.change(screen.getByTestId('password-input'), { target: { value: password } })
fireEvent.submit(screen.getByTestId('login-form'))
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('LoginPage', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset to unauthenticated by default
mockUseSession.mockReturnValue({ status: 'unauthenticated', user: null })
})
// -------------------------------------------------------------------------
// 1. Renders the form
// -------------------------------------------------------------------------
it('renders the login form with username and password fields', () => {
renderLogin()
expect(screen.getByTestId('login-form')).toBeInTheDocument()
expect(screen.getByTestId('username-input')).toBeInTheDocument()
expect(screen.getByTestId('password-input')).toBeInTheDocument()
expect(screen.getByTestId('login-submit')).toBeInTheDocument()
})
it('shows Sign In heading', () => {
renderLogin()
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 2. Successful login
// -------------------------------------------------------------------------
it('navigates to "/" on successful login', async () => {
mockPost.mockResolvedValueOnce({
data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok123' },
response: { status: 200, ok: true },
})
renderLogin()
fillAndSubmit('admin', 'correct-password')
await waitFor(() => {
expect(screen.getByTestId('home-page')).toBeInTheDocument()
})
})
it('calls POST /api/auth/login with the correct body', async () => {
mockPost.mockResolvedValueOnce({
data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok123' },
response: { status: 200, ok: true },
})
renderLogin()
fillAndSubmit('myuser', 'mypassword')
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith('/api/auth/login', {
body: { username: 'myuser', password: 'mypassword' },
})
})
})
// -------------------------------------------------------------------------
// 3. 401 bad credentials
// -------------------------------------------------------------------------
it('shows inline error on 401 and does NOT navigate', async () => {
mockPost.mockResolvedValueOnce({
data: undefined,
error: { detail: 'invalid username or password' },
response: { status: 401, ok: false },
})
renderLogin()
fillAndSubmit('admin', 'wrong-password')
await waitFor(() => {
expect(screen.getByTestId('login-error')).toBeInTheDocument()
})
expect(screen.getByTestId('login-error')).toHaveTextContent(
/incorrect username or password/i,
)
// Should still be on the login form, not navigated away
expect(screen.getByTestId('login-form')).toBeInTheDocument()
})
it('does not include the password in the error message', async () => {
mockPost.mockResolvedValueOnce({
data: undefined,
error: { detail: 'invalid username or password' },
response: { status: 401, ok: false },
})
renderLogin()
fillAndSubmit('admin', 'super-secret-password')
await waitFor(() => {
expect(screen.getByTestId('login-error')).toBeInTheDocument()
})
expect(screen.getByTestId('login-error')).not.toHaveTextContent('super-secret-password')
})
// -------------------------------------------------------------------------
// 5. Unexpected error
// -------------------------------------------------------------------------
it('shows generic error on unexpected network failure', async () => {
mockPost.mockRejectedValueOnce(new Error('Network error'))
renderLogin()
fillAndSubmit('admin', 'password')
await waitFor(() => {
expect(screen.getByTestId('login-error')).toBeInTheDocument()
})
expect(screen.getByTestId('login-error')).toHaveTextContent(/login failed/i)
})
// -------------------------------------------------------------------------
// 4. Already authenticated
// -------------------------------------------------------------------------
it('redirects already-authenticated users to "/"', async () => {
mockUseSession.mockReturnValue({
status: 'authenticated',
user: { username: 'admin', force_password_change: false },
})
renderLogin()
await waitFor(() => {
expect(screen.getByTestId('home-page')).toBeInTheDocument()
})
})
// -------------------------------------------------------------------------
// 6. (M4-T08) 401 with totp_required → step 2
// -------------------------------------------------------------------------
it('switches to TOTP step on 401 with totp_required=true', async () => {
mockPost.mockResolvedValueOnce({
data: undefined,
error: { detail: { totp_required: true } },
response: { status: 401, ok: false },
})
renderLogin()
fillAndSubmit('admin', 'correct-password')
await waitFor(() => {
expect(screen.getByTestId('login-totp-form')).toBeInTheDocument()
})
// Step 1 form should be gone
expect(screen.queryByTestId('login-form')).not.toBeInTheDocument()
// TOTP code input should be visible
expect(screen.getByTestId('totp-code-input')).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 7. (M4-T08) Step 2 correct code → success
// -------------------------------------------------------------------------
it('completes login on correct TOTP code (step 2)', async () => {
// Step 1: trigger totp_required
mockPost.mockResolvedValueOnce({
data: undefined,
error: { detail: { totp_required: true } },
response: { status: 401, ok: false },
})
// Step 2: success
mockPost.mockResolvedValueOnce({
data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok456' },
response: { status: 200, ok: true },
})
renderLogin()
fillAndSubmit('admin', 'correct-password')
await waitFor(() => {
expect(screen.getByTestId('login-totp-form')).toBeInTheDocument()
})
// Enter the TOTP code and submit step 2
fireEvent.change(screen.getByTestId('totp-code-input'), { target: { value: '123456' } })
fireEvent.submit(screen.getByTestId('login-totp-form'))
await waitFor(() => {
expect(screen.getByTestId('home-page')).toBeInTheDocument()
})
// Check step 2 request included totp_code
expect(mockPost).toHaveBeenCalledWith('/api/auth/login', {
body: { username: 'admin', password: 'correct-password', totp_code: '123456' },
})
})
// -------------------------------------------------------------------------
// 8. (M4-T08) Step 2 wrong code → error, stays on step 2
// -------------------------------------------------------------------------
it('shows error on wrong TOTP code and stays on step 2', async () => {
// Step 1: totp_required
mockPost.mockResolvedValueOnce({
data: undefined,
error: { detail: { totp_required: true } },
response: { status: 401, ok: false },
})
// Step 2: wrong code → 401
mockPost.mockResolvedValueOnce({
data: undefined,
error: { detail: 'invalid totp code' },
response: { status: 401, ok: false },
})
renderLogin()
fillAndSubmit('admin', 'correct-password')
await waitFor(() => {
expect(screen.getByTestId('login-totp-form')).toBeInTheDocument()
})
fireEvent.change(screen.getByTestId('totp-code-input'), { target: { value: '000000' } })
fireEvent.submit(screen.getByTestId('login-totp-form'))
await waitFor(() => {
expect(screen.getByTestId('login-error')).toBeInTheDocument()
})
// Should still be on TOTP form
expect(screen.getByTestId('login-totp-form')).toBeInTheDocument()
expect(screen.getByTestId('login-error')).toHaveTextContent(/incorrect verification code/i)
})
// -------------------------------------------------------------------------
// 9. (M4-T08) Step 2 "Back to login" → returns to step 1
// -------------------------------------------------------------------------
it('returns to step 1 when "Back to login" is clicked', async () => {
mockPost.mockResolvedValueOnce({
data: undefined,
error: { detail: { totp_required: true } },
response: { status: 401, ok: false },
})
renderLogin()
fillAndSubmit('admin', 'correct-password')
await waitFor(() => {
expect(screen.getByTestId('login-totp-form')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('totp-back'))
await waitFor(() => {
expect(screen.getByTestId('login-form')).toBeInTheDocument()
})
expect(screen.queryByTestId('login-totp-form')).not.toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 10. (M4-T08) 429 → "too many attempts" message
// -------------------------------------------------------------------------
it('shows "too many attempts" message on 429', async () => {
const { ApiError } = await import('../api/client')
mockPost.mockRejectedValueOnce(new ApiError(429, { detail: 'Too Many Requests' }))
renderLogin()
fillAndSubmit('admin', 'password')
await waitFor(() => {
expect(screen.getByTestId('login-error')).toBeInTheDocument()
})
expect(screen.getByTestId('login-error')).toHaveTextContent(/too many login attempts/i)
})
})
+264
View File
@@ -0,0 +1,264 @@
/**
* LoginPage real login form (M2-T07, extended M4-T08).
*
* Behaviours:
* - Renders a Mantine form with username + password fields (step 1).
* - On submit POST /api/auth/login via apiClient (no CSRF needed; unauthenticated endpoint).
* - On success invalidate ['session'] so SessionProvider re-fetches, then navigate to the
* originally-requested route (from location.state.from) or fall back to '/'.
* - On 401 with totp_required=true switch to step 2 (TOTP code entry).
* - On step 2 submit POST /api/auth/login with {username, password, totp_code}.
* - On 401 plain (bad credentials / wrong TOTP code) show inline error.
* - On 429 (rate-limited) show "too many attempts, please try again later".
* - Already-authenticated users visiting /login redirect to '/'.
*
* Security:
* - Password is retained in state for the step-2 re-submission but is NEVER
* written to localStorage, sessionStorage, or console.
* - totp_code / recovery codes are NEVER logged or persisted.
*/
import { useState } from 'react'
import { useNavigate, useLocation, Navigate } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import {
Container,
Paper,
Title,
TextInput,
PasswordInput,
Button,
Alert,
Stack,
Center,
Text,
} from '@mantine/core'
import { useSession } from '../auth/SessionProvider'
import apiClient, { ApiError } from '../api/client'
import { setCsrfToken } from '../api/csrf'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface LocationState {
from?: { pathname: string }
}
/** The two UI states for the login flow. */
type LoginStep = 'credentials' | 'totp'
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function LoginPage() {
const { status } = useSession()
const navigate = useNavigate()
const location = useLocation()
const queryClient = useQueryClient()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [totpCode, setTotpCode] = useState('')
const [step, setStep] = useState<LoginStep>('credentials')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
// Already authenticated → redirect to intended destination or home.
if (status === 'authenticated') {
const from = (location.state as LocationState)?.from?.pathname ?? '/'
return <Navigate to={from} replace />
}
// ---------------------------------------------------------------------------
// handleCredentialsSubmit — step 1
// ---------------------------------------------------------------------------
async function handleCredentialsSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setLoading(true)
try {
const res = await apiClient.POST('/api/auth/login', {
body: { username, password },
})
if (res.response.status === 401 || !res.data) {
// Check if the backend wants a TOTP code.
// openapi-fetch puts 401 body in res.error (not res.data).
// The shape is { detail: { totp_required: true } } for TOTP-required,
// or { detail: "invalid username or password" } for bad credentials.
const detail = (res.error as { detail?: unknown } | undefined)?.detail
if (detail && typeof detail === 'object' && (detail as { totp_required?: boolean }).totp_required === true) {
// Transition to step 2 — TOTP entry. Password stays in state for re-send.
setStep('totp')
setTotpCode('')
return
}
// Ordinary bad credentials.
setError('Incorrect username or password.')
return
}
// Success: store the CSRF token returned by login.
if (res.data.csrf_token) {
setCsrfToken(res.data.csrf_token)
}
// Refresh session state.
await queryClient.invalidateQueries({ queryKey: ['session'] })
const from = (location.state as LocationState)?.from?.pathname ?? '/'
navigate(from, { replace: true })
} catch (err) {
if (err instanceof ApiError && err.status === 429) {
setError('Too many login attempts. Please try again later.')
} else {
setError('Login failed. Please try again.')
}
} finally {
setLoading(false)
}
}
// ---------------------------------------------------------------------------
// handleTotpSubmit — step 2
// ---------------------------------------------------------------------------
async function handleTotpSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setLoading(true)
try {
const res = await apiClient.POST('/api/auth/login', {
body: { username, password, totp_code: totpCode },
})
if (res.response.status === 401 || !res.data) {
// Wrong code (or expired) — stay on step 2 and show error.
setError('Incorrect verification code. Please try again.')
return
}
// Success.
if (res.data.csrf_token) {
setCsrfToken(res.data.csrf_token)
}
await queryClient.invalidateQueries({ queryKey: ['session'] })
const from = (location.state as LocationState)?.from?.pathname ?? '/'
navigate(from, { replace: true })
} catch (err) {
if (err instanceof ApiError && err.status === 429) {
setError('Too many login attempts. Please try again later.')
} else {
setError('Login failed. Please try again.')
}
} finally {
setLoading(false)
}
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<Center mih="100vh">
<Container size="xs" w="100%">
<Paper shadow="sm" p="xl" radius="md" withBorder>
<Title order={2} mb="lg" ta="center">
Sign In
</Title>
{error && (
<Alert color="red" mb="md" role="alert" data-testid="login-error">
{error}
</Alert>
)}
{step === 'credentials' && (
<form onSubmit={handleCredentialsSubmit} data-testid="login-form">
<Stack gap="md">
<TextInput
label="Username"
placeholder="Enter your username"
value={username}
onChange={(e) => setUsername(e.currentTarget.value)}
required
autoComplete="username"
data-testid="username-input"
/>
<PasswordInput
label="Password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
required
autoComplete="current-password"
data-testid="password-input"
/>
<Button
type="submit"
fullWidth
loading={loading}
mt="sm"
data-testid="login-submit"
>
Sign In
</Button>
</Stack>
</form>
)}
{step === 'totp' && (
<form onSubmit={handleTotpSubmit} data-testid="login-totp-form">
<Stack gap="md">
<Text size="sm" c="dimmed">
Enter the 6-digit code from your authenticator app, or a recovery code.
</Text>
<TextInput
label="Verification Code"
placeholder="6-digit code or recovery code"
value={totpCode}
onChange={(e) => setTotpCode(e.currentTarget.value)}
required
autoComplete="one-time-code"
inputMode="numeric"
data-testid="totp-code-input"
/>
<Button
type="submit"
fullWidth
loading={loading}
mt="sm"
data-testid="totp-submit"
>
Verify
</Button>
<Button
variant="subtle"
size="sm"
onClick={() => {
setStep('credentials')
setError(null)
setTotpCode('')
}}
data-testid="totp-back"
>
Back to login
</Button>
</Stack>
</form>
)}
</Paper>
</Container>
</Center>
)
}
+441
View File
@@ -0,0 +1,441 @@
/**
* Tests for RecordsPage (M2-T10).
*
* Coverage:
* 1. Poo list renders from mocked apiClient GET /api/poo.
* 2. Poo pagination: page 2 requests offset=100.
* 3. Edit poo: clicking Edit opens the modal; form submit calls PATCH with raw (un-encoded)
* PK in the path params (openapi-fetch encodes once; we must not double-encode).
* 4. Delete poo: clicking Delete opens confirmation; confirming calls DELETE and refreshes.
* 5. Location list renders from mocked apiClient GET /api/locations.
* 6. Location pagination: page 2 requests offset=100.
* 7. Edit location: clicking Edit opens modal; form submit calls PATCH with raw PK params.
* 8. Delete location: clicking Delete opens confirmation; confirming calls DELETE.
* 9. Real-encoding regression: stub global fetch; verify actual URL uses single encoding
* (%3A present, %253A absent) for PKs containing colons.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor, fireEvent } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
import { RecordsPage } from './RecordsPage'
import type { LocationRecord } from '../records'
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const POO_RECORD = {
timestamp: '2026-06-12T10:00:00Z',
status: 'done',
latitude: 51.5,
longitude: -0.1,
}
const POO_RECORD_2 = {
timestamp: '2026-06-12T11:00:00Z',
status: 'pending',
latitude: 51.6,
longitude: -0.2,
}
const LOCATION_RECORD: LocationRecord = {
person: 'alice',
datetime: '2026-06-12T09:00:00Z',
latitude: 52.0,
longitude: 1.0,
altitude: 10,
}
// Build a page of 100 items (all identical except for timestamp offset).
function makePooPage(offset: number) {
return Array.from({ length: 100 }, (_, i) => ({
timestamp: `2026-06-12T${String(offset + i).padStart(2, '0')}:00:00Z`,
status: 'done',
latitude: 51.5,
longitude: -0.1,
}))
}
function makeLocationPage(offset: number): LocationRecord[] {
return Array.from({ length: 100 }, (_, i) => ({
person: 'alice',
datetime: `2026-06-12T${String(offset + i).padStart(2, '0')}:00:00Z`,
latitude: 52.0,
longitude: 1.0,
altitude: null,
}))
}
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPatch = vi.fn()
const mockDelete = vi.fn()
vi.mock('../api/client', () => ({
default: {
GET: (...args: unknown[]) => mockGet(...args),
PATCH: (...args: unknown[]) => mockPatch(...args),
DELETE: (...args: unknown[]) => mockDelete(...args),
},
ApiError: class ApiError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown) {
super(`API error ${status}`)
this.name = 'ApiError'
this.status = status
this.body = body
}
},
registerLoginRedirect: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderRecords() {
return renderWithProviders(<RecordsPage />, { initialPath: '/records' })
}
/** Make GET mock respond based on path. */
function setupGetMock({
pooItems = [POO_RECORD],
locationItems = [LOCATION_RECORD],
pooOffset = 0,
locationOffset = 0,
}: {
pooItems?: typeof POO_RECORD[]
locationItems?: typeof LOCATION_RECORD[]
pooOffset?: number
locationOffset?: number
} = {}) {
mockGet.mockImplementation((path: string, opts?: { params?: { query?: { offset?: number } } }) => {
const offset = opts?.params?.query?.offset ?? 0
if (path === '/api/poo') {
return Promise.resolve({
data: { items: pooItems, limit: 100, offset: pooOffset || offset },
response: { status: 200, ok: true },
})
}
if (path === '/api/locations') {
return Promise.resolve({
data: { items: locationItems, limit: 100, offset: locationOffset || offset },
response: { status: 200, ok: true },
})
}
return Promise.resolve({ data: null })
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('RecordsPage — Poo tab', () => {
beforeEach(() => {
vi.clearAllMocks()
setupGetMock()
})
// -------------------------------------------------------------------------
// 1. Poo list renders
// -------------------------------------------------------------------------
it('renders poo records from GET /api/poo', async () => {
renderRecords()
await waitFor(() => {
expect(screen.getByTestId('poo-table')).toBeInTheDocument()
})
expect(screen.getByText('2026-06-12T10:00:00Z')).toBeInTheDocument()
expect(screen.getByText('done')).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 2. Poo pagination: page 2 sends offset=100
// -------------------------------------------------------------------------
it('requests offset=100 when page 2 is selected', async () => {
// Return full page to trigger pagination display.
const page1 = makePooPage(0)
setupGetMock({ pooItems: page1 })
renderRecords()
await waitFor(() => {
expect(screen.getByTestId('poo-pagination')).toBeInTheDocument()
})
// Click page 2.
const page2Button = screen.getByRole('button', { name: '2' })
fireEvent.click(page2Button)
await waitFor(() => {
const allCalls = mockGet.mock.calls.filter((c) => c[0] === '/api/poo')
const page2Call = allCalls.find(
(c) => (c[1]?.params?.query?.offset ?? 0) === 100,
)
expect(page2Call).toBeDefined()
})
})
// -------------------------------------------------------------------------
// 3. Edit poo: opens modal, submit calls PATCH with encoded PK
// -------------------------------------------------------------------------
it('opens EditPooModal when Edit is clicked; submit calls PATCH with raw PK in path params and correct body', async () => {
mockPatch.mockResolvedValue({ data: POO_RECORD, response: { status: 200, ok: true } })
renderRecords()
await waitFor(() => {
expect(screen.getByTestId(`poo-edit-${POO_RECORD.timestamp}`)).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId(`poo-edit-${POO_RECORD.timestamp}`))
// Modal appears
await waitFor(() => {
expect(screen.getByTestId('edit-poo-modal')).toBeInTheDocument()
})
// Change status
const statusInput = screen.getByTestId('poo-status-input') as HTMLInputElement
fireEvent.change(statusInput, { target: { value: 'reviewed' } })
// Submit
fireEvent.submit(screen.getByTestId('edit-poo-form'))
await waitFor(() => {
expect(mockPatch).toHaveBeenCalled()
})
const patchCall = mockPatch.mock.calls[0]
expect(patchCall[0]).toBe('/api/poo/{timestamp}')
// PK must be the raw value — openapi-fetch encodes it once; hooks must not pre-encode.
expect(patchCall[1].params.path.timestamp).toBe(POO_RECORD.timestamp)
// Body must include only non-PK fields
expect(patchCall[1].body).toHaveProperty('status')
expect(patchCall[1].body).not.toHaveProperty('timestamp')
})
// -------------------------------------------------------------------------
// 4. Delete poo: confirmation then DELETE called; list refreshes
// -------------------------------------------------------------------------
it('shows confirmation modal on Delete click; DELETE is called after confirmation', async () => {
mockDelete.mockResolvedValue({ data: null, response: { status: 204, ok: true } })
renderRecords()
await waitFor(() => {
expect(screen.getByTestId(`poo-delete-${POO_RECORD.timestamp}`)).toBeInTheDocument()
})
// Click Delete — confirmation modal appears
fireEvent.click(screen.getByTestId(`poo-delete-${POO_RECORD.timestamp}`))
await waitFor(() => {
expect(screen.getByTestId('confirm-delete-modal')).toBeInTheDocument()
})
// The modal should show a helpful message
expect(screen.getByTestId('confirm-delete-message')).toBeInTheDocument()
// Cancel first — modal should close
fireEvent.click(screen.getByTestId('confirm-delete-cancel'))
await waitFor(() => {
expect(screen.queryByTestId('confirm-delete-modal')).not.toBeInTheDocument()
})
// Reopen and confirm
fireEvent.click(screen.getByTestId(`poo-delete-${POO_RECORD.timestamp}`))
await waitFor(() => {
expect(screen.getByTestId('confirm-delete-confirm')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-delete-confirm'))
await waitFor(() => {
expect(mockDelete).toHaveBeenCalled()
})
const deleteCall = mockDelete.mock.calls[0]
expect(deleteCall[0]).toBe('/api/poo/{timestamp}')
// PK must be the raw value — hooks must not pre-encode; openapi-fetch encodes once.
expect(deleteCall[1].params.path.timestamp).toBe(POO_RECORD.timestamp)
})
})
describe('RecordsPage — Locations tab', () => {
beforeEach(() => {
vi.clearAllMocks()
setupGetMock()
})
// -------------------------------------------------------------------------
// 5. Location list renders
// -------------------------------------------------------------------------
it('renders location records after switching to Locations tab', async () => {
renderRecords()
// Switch to Locations tab
await waitFor(() => {
expect(screen.getByTestId('tab-locations')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('tab-locations'))
await waitFor(() => {
expect(screen.getByTestId('location-table')).toBeInTheDocument()
})
expect(screen.getByText('alice')).toBeInTheDocument()
expect(screen.getByText('2026-06-12T09:00:00Z')).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 6. Location pagination: page 2 sends offset=100
// -------------------------------------------------------------------------
it('requests offset=100 for locations when page 2 is selected', async () => {
const page1 = makeLocationPage(0)
setupGetMock({ locationItems: page1 })
renderRecords()
// Switch to Locations tab
await waitFor(() => {
expect(screen.getByTestId('tab-locations')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('tab-locations'))
await waitFor(() => {
expect(screen.getByTestId('location-pagination')).toBeInTheDocument()
})
const page2Button = screen.getByRole('button', { name: '2' })
fireEvent.click(page2Button)
await waitFor(() => {
const allCalls = mockGet.mock.calls.filter((c) => c[0] === '/api/locations')
const page2Call = allCalls.find(
(c) => (c[1]?.params?.query?.offset ?? 0) === 100,
)
expect(page2Call).toBeDefined()
})
})
// -------------------------------------------------------------------------
// 7. Edit location: opens modal, submit calls PATCH with encoded PK
// -------------------------------------------------------------------------
it('opens EditLocationModal; submit calls PATCH with raw person+datetime in path params', async () => {
mockPatch.mockResolvedValue({ data: LOCATION_RECORD, response: { status: 200, ok: true } })
renderRecords()
fireEvent.click(screen.getByTestId('tab-locations'))
const rowKey = `${LOCATION_RECORD.person}__${LOCATION_RECORD.datetime}`
await waitFor(() => {
expect(screen.getByTestId(`location-edit-${rowKey}`)).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId(`location-edit-${rowKey}`))
await waitFor(() => {
expect(screen.getByTestId('edit-location-modal')).toBeInTheDocument()
})
// PK shown read-only in the modal (may appear more than once in the page: table + modal)
const modalEl = screen.getByTestId('edit-location-modal')
expect(modalEl).toBeInTheDocument()
// 'alice' and datetime appear in modal read-only text
expect(modalEl.textContent).toContain('alice')
expect(modalEl.textContent).toContain('2026-06-12T09:00:00Z')
// Submit
fireEvent.submit(screen.getByTestId('edit-location-form'))
await waitFor(() => {
expect(mockPatch).toHaveBeenCalled()
})
const patchCall = mockPatch.mock.calls[0]
expect(patchCall[0]).toBe('/api/locations/{person}/{datetime}')
// PKs must be raw — hooks must not pre-encode; openapi-fetch encodes once.
expect(patchCall[1].params.path.person).toBe(LOCATION_RECORD.person)
expect(patchCall[1].params.path.datetime).toBe(LOCATION_RECORD.datetime)
// Body must NOT contain PK fields
expect(patchCall[1].body).not.toHaveProperty('person')
expect(patchCall[1].body).not.toHaveProperty('datetime')
expect(patchCall[1].body).toHaveProperty('latitude')
expect(patchCall[1].body).toHaveProperty('longitude')
})
// -------------------------------------------------------------------------
// 8. Delete location: confirmation then DELETE called
// -------------------------------------------------------------------------
it('shows confirmation modal on Delete; DELETE is called with raw PK params', async () => {
mockDelete.mockResolvedValue({ data: null, response: { status: 204, ok: true } })
renderRecords()
fireEvent.click(screen.getByTestId('tab-locations'))
const rowKey = `${LOCATION_RECORD.person}__${LOCATION_RECORD.datetime}`
await waitFor(() => {
expect(screen.getByTestId(`location-delete-${rowKey}`)).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId(`location-delete-${rowKey}`))
await waitFor(() => {
expect(screen.getByTestId('confirm-delete-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-delete-confirm'))
await waitFor(() => {
expect(mockDelete).toHaveBeenCalled()
})
const deleteCall = mockDelete.mock.calls[0]
expect(deleteCall[0]).toBe('/api/locations/{person}/{datetime}')
// PKs must be raw — hooks must not pre-encode; openapi-fetch encodes once.
expect(deleteCall[1].params.path.person).toBe(LOCATION_RECORD.person)
expect(deleteCall[1].params.path.datetime).toBe(LOCATION_RECORD.datetime)
})
})
// ---------------------------------------------------------------------------
// Additional: multiple poo records with correct timestamps
// ---------------------------------------------------------------------------
describe('RecordsPage — multiple poo rows', () => {
beforeEach(() => {
vi.clearAllMocks()
setupGetMock({ pooItems: [POO_RECORD, POO_RECORD_2] })
})
it('renders both rows', async () => {
renderRecords()
await waitFor(() => {
expect(screen.getByTestId('poo-table')).toBeInTheDocument()
})
expect(screen.getByText('2026-06-12T10:00:00Z')).toBeInTheDocument()
expect(screen.getByText('2026-06-12T11:00:00Z')).toBeInTheDocument()
})
})
+375
View File
@@ -0,0 +1,375 @@
/**
* RecordsPage paginated lists + edit/delete for poo and location records (M2-T10).
*
* - Poo list: GET /api/poo, query key ['poo', {limit, offset}], page size 100.
* - Location list: GET /api/locations, query key ['locations', {limit, offset}], page size 100.
* - Edit and delete use reusable components from src/records/.
* - Delete has a二次确认 modal before calling DELETE.
* - Pagination with Mantine Pagination; next/prev fetches per-page (no full-table pull).
*/
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
Container,
Title,
Table,
Pagination,
Button,
Group,
Tabs,
Text,
Loader,
Center,
Alert,
Stack,
Badge,
ScrollArea,
} from '@mantine/core'
import apiClient from '../api/client'
import { EditPooModal, EditLocationModal, ConfirmDeleteModal } from '../records'
import { useDeletePoo, useDeleteLocation } from '../records'
import type { PooRecord, LocationRecord } from '../records'
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PAGE_SIZE = 100
// ---------------------------------------------------------------------------
// Poo list section
// ---------------------------------------------------------------------------
function PooList() {
const [page, setPage] = useState(1)
const offset = (page - 1) * PAGE_SIZE
const { data, isLoading, isError } = useQuery({
queryKey: ['poo', { limit: PAGE_SIZE, offset }],
queryFn: async () => {
const res = await apiClient.GET('/api/poo', {
params: { query: { limit: PAGE_SIZE, offset } },
})
return res.data
},
})
const [editRecord, setEditRecord] = useState<PooRecord | null>(null)
const [deleteRecord, setDeleteRecord] = useState<PooRecord | null>(null)
const deleteMutation = useDeletePoo()
async function handleDeleteConfirm() {
if (!deleteRecord) return
try {
await deleteMutation.mutateAsync(deleteRecord.timestamp)
setDeleteRecord(null)
} catch {
// Leave the modal open so the user can retry; error display is in the modal loading state.
}
}
if (isLoading) {
return (
<Center pt="xl" data-testid="poo-loading">
<Loader />
</Center>
)
}
if (isError || !data) {
return (
<Alert color="red" data-testid="poo-load-error">
Failed to load poo records. Please refresh.
</Alert>
)
}
const totalPages = data.items.length === PAGE_SIZE ? page + 1 : page
return (
<Stack gap="md">
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed" data-testid="poo-count">
Page {page} · {data.items.length} record{data.items.length !== 1 ? 's' : ''} shown
</Text>
<Badge variant="outline" color="orange">
offset {offset}
</Badge>
</Group>
<ScrollArea>
<Table striped highlightOnHover withTableBorder data-testid="poo-table">
<Table.Thead>
<Table.Tr>
<Table.Th>Timestamp</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Latitude</Table.Th>
<Table.Th>Longitude</Table.Th>
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.items.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={5}>
<Text c="dimmed" ta="center" size="sm">
No records.
</Text>
</Table.Td>
</Table.Tr>
) : (
data.items.map((row) => (
<Table.Tr key={row.timestamp} data-testid={`poo-row-${row.timestamp}`}>
<Table.Td style={{ whiteSpace: 'nowrap' }}>{row.timestamp}</Table.Td>
<Table.Td>{row.status}</Table.Td>
<Table.Td>{row.latitude}</Table.Td>
<Table.Td>{row.longitude}</Table.Td>
<Table.Td>
<Group justify="flex-end" gap="xs">
<Button
size="xs"
variant="outline"
onClick={() => setEditRecord(row)}
data-testid={`poo-edit-${row.timestamp}`}
>
Edit
</Button>
<Button
size="xs"
variant="outline"
color="red"
onClick={() => setDeleteRecord(row)}
data-testid={`poo-delete-${row.timestamp}`}
>
Delete
</Button>
</Group>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</ScrollArea>
{totalPages > 1 && (
<Pagination
value={page}
onChange={setPage}
total={totalPages}
data-testid="poo-pagination"
/>
)}
{/* Edit modal */}
{editRecord && (
<EditPooModal
record={editRecord}
onClose={() => setEditRecord(null)}
onSaved={() => setEditRecord(null)}
/>
)}
{/* Delete confirmation modal */}
{deleteRecord && (
<ConfirmDeleteModal
message={`Delete poo record at ${deleteRecord.timestamp}?`}
loading={deleteMutation.isPending}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteRecord(null)}
/>
)}
</Stack>
)
}
// ---------------------------------------------------------------------------
// Location list section
// ---------------------------------------------------------------------------
function LocationList() {
const [page, setPage] = useState(1)
const offset = (page - 1) * PAGE_SIZE
const { data, isLoading, isError } = useQuery({
queryKey: ['locations', { limit: PAGE_SIZE, offset }],
queryFn: async () => {
const res = await apiClient.GET('/api/locations', {
params: { query: { limit: PAGE_SIZE, offset } },
})
return res.data
},
})
const [editRecord, setEditRecord] = useState<LocationRecord | null>(null)
const [deleteRecord, setDeleteRecord] = useState<LocationRecord | null>(null)
const deleteMutation = useDeleteLocation()
async function handleDeleteConfirm() {
if (!deleteRecord) return
try {
await deleteMutation.mutateAsync({
person: deleteRecord.person,
datetime: deleteRecord.datetime,
})
setDeleteRecord(null)
} catch {
// Leave modal open.
}
}
if (isLoading) {
return (
<Center pt="xl" data-testid="location-loading">
<Loader />
</Center>
)
}
if (isError || !data) {
return (
<Alert color="red" data-testid="location-load-error">
Failed to load location records. Please refresh.
</Alert>
)
}
const totalPages = data.items.length === PAGE_SIZE ? page + 1 : page
return (
<Stack gap="md">
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed" data-testid="location-count">
Page {page} · {data.items.length} record{data.items.length !== 1 ? 's' : ''} shown
</Text>
<Badge variant="outline" color="blue">
offset {offset}
</Badge>
</Group>
<ScrollArea>
<Table striped highlightOnHover withTableBorder data-testid="location-table">
<Table.Thead>
<Table.Tr>
<Table.Th>Person</Table.Th>
<Table.Th>Datetime</Table.Th>
<Table.Th>Latitude</Table.Th>
<Table.Th>Longitude</Table.Th>
<Table.Th>Altitude</Table.Th>
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.items.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={6}>
<Text c="dimmed" ta="center" size="sm">
No records.
</Text>
</Table.Td>
</Table.Tr>
) : (
data.items.map((row) => {
const rowKey = `${row.person}__${row.datetime}`
return (
<Table.Tr key={rowKey} data-testid={`location-row-${rowKey}`}>
<Table.Td>{row.person}</Table.Td>
<Table.Td style={{ whiteSpace: 'nowrap' }}>{row.datetime}</Table.Td>
<Table.Td>{row.latitude}</Table.Td>
<Table.Td>{row.longitude}</Table.Td>
<Table.Td>{row.altitude ?? '—'}</Table.Td>
<Table.Td>
<Group justify="flex-end" gap="xs">
<Button
size="xs"
variant="outline"
onClick={() => setEditRecord(row)}
data-testid={`location-edit-${rowKey}`}
>
Edit
</Button>
<Button
size="xs"
variant="outline"
color="red"
onClick={() => setDeleteRecord(row)}
data-testid={`location-delete-${rowKey}`}
>
Delete
</Button>
</Group>
</Table.Td>
</Table.Tr>
)
})
)}
</Table.Tbody>
</Table>
</ScrollArea>
{totalPages > 1 && (
<Pagination
value={page}
onChange={setPage}
total={totalPages}
data-testid="location-pagination"
/>
)}
{/* Edit modal */}
{editRecord && (
<EditLocationModal
record={editRecord}
onClose={() => setEditRecord(null)}
onSaved={() => setEditRecord(null)}
/>
)}
{/* Delete confirmation modal */}
{deleteRecord && (
<ConfirmDeleteModal
message={`Delete location record for ${deleteRecord.person} at ${deleteRecord.datetime}?`}
loading={deleteMutation.isPending}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteRecord(null)}
/>
)}
</Stack>
)
}
// ---------------------------------------------------------------------------
// RecordsPage — top-level
// ---------------------------------------------------------------------------
export function RecordsPage() {
return (
<Container size="xl" pt="xl" pb="xl" data-testid="records-page">
<Title order={2} mb="lg">
Records
</Title>
<Tabs defaultValue="poo">
<Tabs.List mb="md">
<Tabs.Tab value="poo" data-testid="tab-poo">
Poo
</Tabs.Tab>
<Tabs.Tab value="locations" data-testid="tab-locations">
Locations
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="poo">
<PooList />
</Tabs.Panel>
<Tabs.Panel value="locations">
<LocationList />
</Tabs.Panel>
</Tabs>
</Container>
)
}
+344
View File
@@ -0,0 +1,344 @@
/**
* Tests for TotpSettings (M4-T08).
*
* Strategy: vi.mock the auth/totp module so we can control API responses
* without a real server. We also mock the api/client module for ApiError.
*
* Coverage:
* 1. Shows loading spinner while fetching TOTP status.
* 2. Shows error when TOTP status fetch fails.
* 3. Shows "Disabled" badge and enable button when TOTP is disabled.
* 4. Enable flow: clicking setup shows QR code + recovery codes + confirm form.
* 5. Confirm with correct code TOTP enabled (status refetched).
* 6. Confirm with wrong code shows error, stays on confirm form.
* 7. Shows "Enabled" badge and disable form when TOTP is enabled.
* 8. Disable with password TOTP disabled.
* 9. Disable with TOTP code TOTP disabled.
* 10. Disable without providing password or code shows validation error.
* 11. Setup error shows error message.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor, fireEvent } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
import { TotpSettings } from './TotpSettings'
// ---------------------------------------------------------------------------
// Mock auth/totp
// ---------------------------------------------------------------------------
const mockGetTotpStatus = vi.fn()
const mockSetupTotp = vi.fn()
const mockEnableTotp = vi.fn()
const mockDisableTotp = vi.fn()
vi.mock('../auth/totp', () => ({
getTotpStatus: (...args: unknown[]) => mockGetTotpStatus(...args),
setupTotp: (...args: unknown[]) => mockSetupTotp(...args),
enableTotp: (...args: unknown[]) => mockEnableTotp(...args),
disableTotp: (...args: unknown[]) => mockDisableTotp(...args),
}))
// ---------------------------------------------------------------------------
// Mock api/client (for ApiError)
// ---------------------------------------------------------------------------
vi.mock('../api/client', () => ({
default: {
GET: vi.fn(),
POST: vi.fn(),
PUT: vi.fn(),
},
ApiError: class ApiError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown) {
super(`API error ${status}`)
this.name = 'ApiError'
this.status = status
this.body = body
}
},
registerLoginRedirect: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Fixture
// ---------------------------------------------------------------------------
const MOCK_SETUP_DATA = {
secret: 'JBSWY3DPEHPK3PXP',
otpauth_uri: 'otpauth://totp/TestApp:admin?secret=JBSWY3DPEHPK3PXP&issuer=TestApp',
recovery_codes: ['abcd-1234', 'efgh-5678', 'ijkl-9012'],
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderTotp() {
return renderWithProviders(<TotpSettings />, { initialPath: '/config' })
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('TotpSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// -------------------------------------------------------------------------
// 1. Loading state
// -------------------------------------------------------------------------
it('shows loading spinner while fetching TOTP status', async () => {
// Never resolves during this test
mockGetTotpStatus.mockReturnValue(new Promise(() => {}))
renderTotp()
await waitFor(() => {
expect(screen.getByTestId('totp-status-loading')).toBeInTheDocument()
})
})
// -------------------------------------------------------------------------
// 2. Error state
// -------------------------------------------------------------------------
it('shows error when TOTP status fetch fails', async () => {
mockGetTotpStatus.mockRejectedValue(new Error('Network error'))
renderTotp()
await waitFor(() => {
expect(screen.getByTestId('totp-status-error')).toBeInTheDocument()
})
})
// -------------------------------------------------------------------------
// 3. Disabled state
// -------------------------------------------------------------------------
it('shows Disabled badge and enable button when TOTP is disabled', async () => {
mockGetTotpStatus.mockResolvedValue({ enabled: false })
renderTotp()
await waitFor(() => {
expect(screen.getByTestId('totp-status-badge')).toBeInTheDocument()
})
expect(screen.getByTestId('totp-status-badge')).toHaveTextContent(/disabled/i)
expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 4. Enable flow: setup shows QR + recovery codes + confirm form
// -------------------------------------------------------------------------
it('shows QR code, secret, and recovery codes after clicking setup', async () => {
mockGetTotpStatus.mockResolvedValue({ enabled: false })
mockSetupTotp.mockResolvedValue(MOCK_SETUP_DATA)
renderTotp()
await waitFor(() => {
expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('totp-setup-button'))
await waitFor(() => {
expect(screen.getByTestId('totp-qr-code')).toBeInTheDocument()
})
expect(screen.getByTestId('totp-secret')).toHaveTextContent('JBSWY3DPEHPK3PXP')
expect(screen.getByTestId('totp-recovery-codes')).toBeInTheDocument()
expect(screen.getByTestId('totp-recovery-codes')).toHaveTextContent('abcd-1234')
expect(screen.getByTestId('totp-confirm-form')).toBeInTheDocument()
expect(screen.getByTestId('totp-confirm-input')).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 5. Confirm with correct code → enabled
// -------------------------------------------------------------------------
it('enables TOTP after confirming with correct code', async () => {
mockGetTotpStatus
.mockResolvedValueOnce({ enabled: false })
.mockResolvedValueOnce({ enabled: true })
mockSetupTotp.mockResolvedValue(MOCK_SETUP_DATA)
mockEnableTotp.mockResolvedValue(undefined)
renderTotp()
await waitFor(() => {
expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('totp-setup-button'))
await waitFor(() => {
expect(screen.getByTestId('totp-confirm-form')).toBeInTheDocument()
})
fireEvent.change(screen.getByTestId('totp-confirm-input'), { target: { value: '123456' } })
fireEvent.submit(screen.getByTestId('totp-confirm-form'))
await waitFor(() => {
expect(mockEnableTotp).toHaveBeenCalledWith('123456')
})
// After enable the status refetches → shows enabled
await waitFor(() => {
expect(screen.getByTestId('totp-status-badge')).toHaveTextContent(/enabled/i)
})
})
// -------------------------------------------------------------------------
// 6. Confirm with wrong code → error
// -------------------------------------------------------------------------
it('shows error on wrong TOTP code during enable', async () => {
mockGetTotpStatus.mockResolvedValue({ enabled: false })
mockSetupTotp.mockResolvedValue(MOCK_SETUP_DATA)
const { ApiError } = await import('../api/client')
mockEnableTotp.mockRejectedValue(new ApiError(400, { detail: 'invalid code' }))
renderTotp()
await waitFor(() => {
expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('totp-setup-button'))
await waitFor(() => {
expect(screen.getByTestId('totp-confirm-form')).toBeInTheDocument()
})
fireEvent.change(screen.getByTestId('totp-confirm-input'), { target: { value: '000000' } })
fireEvent.submit(screen.getByTestId('totp-confirm-form'))
await waitFor(() => {
expect(screen.getByTestId('totp-confirm-error')).toBeInTheDocument()
})
// Should still show confirm form (not enabled)
expect(screen.getByTestId('totp-confirm-form')).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 7. Enabled state
// -------------------------------------------------------------------------
it('shows Enabled badge and disable form when TOTP is enabled', async () => {
mockGetTotpStatus.mockResolvedValue({ enabled: true })
renderTotp()
await waitFor(() => {
expect(screen.getByTestId('totp-status-badge')).toBeInTheDocument()
})
expect(screen.getByTestId('totp-status-badge')).toHaveTextContent(/enabled/i)
expect(screen.getByTestId('totp-disable-form')).toBeInTheDocument()
expect(screen.getByTestId('totp-disable-button')).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 8. Disable with password
// -------------------------------------------------------------------------
it('disables TOTP when correct password is provided', async () => {
mockGetTotpStatus
.mockResolvedValueOnce({ enabled: true })
.mockResolvedValueOnce({ enabled: false })
mockDisableTotp.mockResolvedValue(undefined)
renderTotp()
await waitFor(() => {
expect(screen.getByTestId('totp-disable-form')).toBeInTheDocument()
})
fireEvent.change(screen.getByTestId('totp-disable-password'), {
target: { value: 'my-password' },
})
fireEvent.submit(screen.getByTestId('totp-disable-form'))
await waitFor(() => {
expect(mockDisableTotp).toHaveBeenCalledWith({ password: 'my-password', code: null })
})
await waitFor(() => {
expect(screen.getByTestId('totp-status-badge')).toHaveTextContent(/disabled/i)
})
})
// -------------------------------------------------------------------------
// 9. Disable with TOTP code
// -------------------------------------------------------------------------
it('disables TOTP when correct TOTP code is provided', async () => {
mockGetTotpStatus
.mockResolvedValueOnce({ enabled: true })
.mockResolvedValueOnce({ enabled: false })
mockDisableTotp.mockResolvedValue(undefined)
renderTotp()
await waitFor(() => {
expect(screen.getByTestId('totp-disable-form')).toBeInTheDocument()
})
fireEvent.change(screen.getByTestId('totp-disable-code'), { target: { value: '654321' } })
fireEvent.submit(screen.getByTestId('totp-disable-form'))
await waitFor(() => {
expect(mockDisableTotp).toHaveBeenCalledWith({ password: null, code: '654321' })
})
})
// -------------------------------------------------------------------------
// 10. Disable without credentials → validation error
// -------------------------------------------------------------------------
it('shows validation error when disable submitted without password or code', async () => {
mockGetTotpStatus.mockResolvedValue({ enabled: true })
renderTotp()
await waitFor(() => {
expect(screen.getByTestId('totp-disable-form')).toBeInTheDocument()
})
// Submit without filling in anything
fireEvent.submit(screen.getByTestId('totp-disable-form'))
await waitFor(() => {
expect(screen.getByTestId('totp-disable-error')).toBeInTheDocument()
})
expect(mockDisableTotp).not.toHaveBeenCalled()
})
// -------------------------------------------------------------------------
// 11. Setup error
// -------------------------------------------------------------------------
it('shows error when TOTP setup request fails', async () => {
mockGetTotpStatus.mockResolvedValue({ enabled: false })
mockSetupTotp.mockRejectedValue(new Error('Network error'))
renderTotp()
await waitFor(() => {
expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('totp-setup-button'))
await waitFor(() => {
expect(screen.getByTestId('totp-enable-error')).toBeInTheDocument()
})
})
})
+360
View File
@@ -0,0 +1,360 @@
/**
* TotpSettings TOTP two-factor auth management panel (M4-T08).
*
* Intended to be embedded in the settings/config area of the app.
* Handles the full TOTP lifecycle:
* - Status probe : GET /api/auth/totp
* - Enable flow : setup show QR code + one-time recovery codes confirm with code
* - Disable flow : prompt for current password or TOTP code disable
*
* Security notes:
* - secret and recovery_codes from TotpSetupResponse are held ONLY in component
* state; they are NEVER written to localStorage, sessionStorage, or console.
* - Recovery codes are displayed exactly once (during the setup flow); after the
* user confirms with a code and TOTP is enabled the data is discarded.
* - Passwords typed in the disable form are NEVER logged or persisted.
*/
import { useState, useCallback } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
Paper,
Title,
Text,
Button,
Alert,
Stack,
Group,
TextInput,
PasswordInput,
Divider,
Badge,
Loader,
Center,
Code,
List,
} from '@mantine/core'
import { QRCodeSVG } from 'qrcode.react'
import { getTotpStatus, setupTotp, enableTotp, disableTotp } from '../auth/totp'
import { ApiError } from '../api/client'
import type { TotpSetupResponse } from '../auth/totp'
// ---------------------------------------------------------------------------
// Sub-panel: Enable flow
// ---------------------------------------------------------------------------
interface EnablePanelProps {
onEnabled: () => void
}
function EnablePanel({ onEnabled }: EnablePanelProps) {
const [setupData, setSetupData] = useState<TotpSetupResponse | null>(null)
const [confirmCode, setConfirmCode] = useState('')
const [status, setStatus] = useState<'idle' | 'setup-loading' | 'confirm' | 'confirming' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState<string | null>(null)
async function handleSetup() {
setStatus('setup-loading')
setErrorMsg(null)
try {
const data = await setupTotp()
// data contains one-time secret + recovery_codes — keep only in state.
setSetupData(data)
setStatus('confirm')
} catch {
setErrorMsg('Failed to start TOTP setup. Please try again.')
setStatus('error')
}
}
async function handleEnable(e: React.FormEvent) {
e.preventDefault()
setErrorMsg(null)
setStatus('confirming')
try {
await enableTotp(confirmCode)
// Discard secret + recovery codes from memory now that TOTP is enabled.
setSetupData(null)
setConfirmCode('')
onEnabled()
} catch (err) {
let msg = 'Failed to enable TOTP. Check the code and try again.'
if (err instanceof ApiError && err.status === 400) {
msg = 'Incorrect code or no pending setup. Please start the setup again.'
}
setErrorMsg(msg)
setStatus('confirm')
}
}
// ---------------------------------------------------------------------------
// Render: idle — show "Start Setup" button
// ---------------------------------------------------------------------------
if (status === 'idle' || status === 'error') {
return (
<Stack gap="sm">
<Text size="sm">
Two-factor authentication is not enabled. Enable it to require a verification code
each time you sign in.
</Text>
{errorMsg && (
<Alert color="red" data-testid="totp-enable-error">
{errorMsg}
</Alert>
)}
<Button onClick={handleSetup} data-testid="totp-setup-button">
Enable Two-Factor Authentication
</Button>
</Stack>
)
}
// ---------------------------------------------------------------------------
// Render: setup-loading
// ---------------------------------------------------------------------------
if (status === 'setup-loading') {
return (
<Center>
<Loader data-testid="totp-setup-loading" />
</Center>
)
}
// ---------------------------------------------------------------------------
// Render: confirm — show QR code, secret, recovery codes, confirmation form
// ---------------------------------------------------------------------------
if ((status === 'confirm' || status === 'confirming') && setupData) {
return (
<Stack gap="md">
<Alert color="yellow" data-testid="totp-setup-warning">
Save your recovery codes now they will only be shown once.
</Alert>
<Text size="sm" fw={500}>
1. Scan this QR code with your authenticator app (e.g. Google Authenticator, Authy):
</Text>
<Center data-testid="totp-qr-code">
<QRCodeSVG value={setupData.otpauth_uri} size={180} />
</Center>
<Text size="sm" c="dimmed" ta="center">
Or enter the key manually:
</Text>
<Code block data-testid="totp-secret">
{setupData.secret}
</Code>
<Divider />
<Text size="sm" fw={500}>
2. Save these recovery codes in a secure place. Each code can be used once
if you lose access to your authenticator:
</Text>
<Paper withBorder p="sm" data-testid="totp-recovery-codes">
<List spacing="xs" size="sm">
{setupData.recovery_codes.map((code) => (
<List.Item key={code}>
<Code>{code}</Code>
</List.Item>
))}
</List>
</Paper>
<Divider />
<Text size="sm" fw={500}>
3. Enter the 6-digit code from your authenticator app to confirm setup:
</Text>
{errorMsg && (
<Alert color="red" data-testid="totp-confirm-error">
{errorMsg}
</Alert>
)}
<form onSubmit={handleEnable} data-testid="totp-confirm-form">
<Stack gap="sm">
<TextInput
label="Verification Code"
placeholder="6-digit code"
value={confirmCode}
onChange={(e) => setConfirmCode(e.currentTarget.value)}
required
autoComplete="one-time-code"
inputMode="numeric"
data-testid="totp-confirm-input"
/>
<Button
type="submit"
loading={status === 'confirming'}
data-testid="totp-confirm-button"
>
Confirm and Enable
</Button>
</Stack>
</form>
</Stack>
)
}
return null
}
// ---------------------------------------------------------------------------
// Sub-panel: Disable flow
// ---------------------------------------------------------------------------
interface DisablePanelProps {
onDisabled: () => void
}
function DisablePanel({ onDisabled }: DisablePanelProps) {
const [disablePassword, setDisablePassword] = useState('')
const [disableCode, setDisableCode] = useState('')
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
async function handleDisable(e: React.FormEvent) {
e.preventDefault()
setErrorMsg(null)
// At least one of password or code must be provided.
if (!disablePassword && !disableCode) {
setErrorMsg('Please provide your current password or a TOTP code.')
return
}
setIsSubmitting(true)
try {
await disableTotp({
password: disablePassword || null,
code: disableCode || null,
})
setDisablePassword('')
setDisableCode('')
onDisabled()
} catch (err) {
let msg = 'Failed to disable TOTP. Please try again.'
if (err instanceof ApiError && err.status === 400) {
msg = 'Incorrect password or code. Please try again.'
}
setErrorMsg(msg)
} finally {
setIsSubmitting(false)
}
}
return (
<Stack gap="md">
<Alert color="blue" data-testid="totp-enabled-notice">
Two-factor authentication is enabled. To disable it, enter your current password
or a valid verification code.
</Alert>
{errorMsg && (
<Alert color="red" data-testid="totp-disable-error">
{errorMsg}
</Alert>
)}
<form onSubmit={handleDisable} data-testid="totp-disable-form">
<Stack gap="sm">
<PasswordInput
label="Current Password"
placeholder="Enter your password (or use a TOTP code below)"
value={disablePassword}
onChange={(e) => setDisablePassword(e.currentTarget.value)}
autoComplete="current-password"
data-testid="totp-disable-password"
/>
<Text size="xs" c="dimmed" ta="center">
or
</Text>
<TextInput
label="TOTP Code"
placeholder="6-digit code from your authenticator"
value={disableCode}
onChange={(e) => setDisableCode(e.currentTarget.value)}
autoComplete="one-time-code"
inputMode="numeric"
data-testid="totp-disable-code"
/>
<Button
type="submit"
color="red"
variant="outline"
loading={isSubmitting}
data-testid="totp-disable-button"
>
Disable Two-Factor Authentication
</Button>
</Stack>
</form>
</Stack>
)
}
// ---------------------------------------------------------------------------
// TotpSettings — main exported component
// ---------------------------------------------------------------------------
export function TotpSettings() {
const queryClient = useQueryClient()
const {
data: totpStatus,
isLoading,
isError,
} = useQuery({
queryKey: ['totp-status'],
queryFn: getTotpStatus,
// No automatic retries — the test QueryClient is configured with retry: false.
// Network errors are shown immediately via the error state.
retry: false,
})
// Invalidate the TOTP status query to refetch after enable/disable.
const refetchStatus = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: ['totp-status'] })
}, [queryClient])
return (
<Paper withBorder p="md" radius="md" data-testid="totp-settings">
<Group justify="space-between" mb="md" wrap="nowrap">
<Title order={4}>Two-Factor Authentication (TOTP)</Title>
{totpStatus && (
<Badge
color={totpStatus.enabled ? 'green' : 'gray'}
variant="outline"
data-testid="totp-status-badge"
>
{totpStatus.enabled ? 'Enabled' : 'Disabled'}
</Badge>
)}
</Group>
{isLoading && (
<Center>
<Loader data-testid="totp-status-loading" />
</Center>
)}
{isError && (
<Alert color="red" data-testid="totp-status-error">
Failed to load TOTP status. Please refresh the page.
</Alert>
)}
{totpStatus && !totpStatus.enabled && <EnablePanel onEnabled={refetchStatus} />}
{totpStatus && totpStatus.enabled && <DisablePanel onDisabled={refetchStatus} />}
</Paper>
)
}
@@ -0,0 +1,47 @@
/**
* ConfirmDeleteModal generic二次确认 (confirm-before-delete) dialog.
* Used by both poo and location delete flows (M2-T10, reused by T09).
*/
import { Modal, Stack, Text, Button, Group } from '@mantine/core'
export interface ConfirmDeleteModalProps {
/** Message shown to the user, e.g. "Delete this poo record?" */
message: string
/** Whether the delete action is in flight. */
loading?: boolean
onConfirm: () => void
onCancel: () => void
}
export function ConfirmDeleteModal({
message,
loading = false,
onConfirm,
onCancel,
}: ConfirmDeleteModalProps) {
return (
<Modal opened onClose={onCancel} title="Confirm Delete" size="sm" data-testid="confirm-delete-modal">
<Stack gap="md">
<Text data-testid="confirm-delete-message">{message}</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={onCancel}
data-testid="confirm-delete-cancel"
>
Cancel
</Button>
<Button
color="red"
loading={loading}
onClick={onConfirm}
data-testid="confirm-delete-confirm"
>
Delete
</Button>
</Group>
</Stack>
</Modal>
)
}
+141
View File
@@ -0,0 +1,141 @@
/**
* EditLocationModal edit non-PK fields of a location record (M2-T10, reused by T09).
*
* Editable fields: latitude, longitude, altitude.
* Read-only: person + datetime (composite PK).
*/
import { useState } from 'react'
import {
Modal,
Stack,
NumberInput,
Button,
Group,
Text,
Alert,
} from '@mantine/core'
import { useUpdateLocation } from './hooks'
import type { LocationRecord, LocationUpdateBody } from './hooks'
export interface EditLocationModalProps {
record: LocationRecord
onClose: () => void
onSaved: () => void
}
export function EditLocationModal({ record, onClose, onSaved }: EditLocationModalProps) {
const [latitude, setLatitude] = useState<number | string>(record.latitude)
const [longitude, setLongitude] = useState<number | string>(record.longitude)
const [altitude, setAltitude] = useState<number | string>(record.altitude ?? '')
const [error, setError] = useState<string | null>(null)
const updateMutation = useUpdateLocation()
function validate(): string | null {
const lat = Number(latitude)
const lng = Number(longitude)
if (isNaN(lat) || lat < -90 || lat > 90) return 'Latitude must be a number between -90 and 90.'
if (isNaN(lng) || lng < -180 || lng > 180)
return 'Longitude must be a number between -180 and 180.'
// Altitude is optional — blank is fine.
if (altitude !== '' && altitude !== null && isNaN(Number(altitude)))
return 'Altitude must be a number or left blank.'
return null
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
const validationError = validate()
if (validationError) {
setError(validationError)
return
}
const body: LocationUpdateBody = {
latitude: Number(latitude),
longitude: Number(longitude),
altitude: altitude === '' || altitude === null ? null : Number(altitude),
}
try {
await updateMutation.mutateAsync({
person: record.person,
datetime: record.datetime,
body,
})
onSaved()
onClose()
} catch {
setError('Failed to save. Please try again.')
}
}
return (
<Modal
opened
onClose={onClose}
title="Edit Location Record"
size="sm"
data-testid="edit-location-modal"
>
<form onSubmit={handleSubmit} data-testid="edit-location-form">
<Stack gap="sm">
{/* Composite PK — read-only */}
<Text size="sm" c="dimmed">
<strong>Person (PK):</strong> {record.person}
</Text>
<Text size="sm" c="dimmed">
<strong>Datetime (PK):</strong> {record.datetime}
</Text>
<NumberInput
label="Latitude"
value={latitude}
onChange={(val) => setLatitude(val)}
decimalScale={6}
data-testid="location-latitude-input"
/>
<NumberInput
label="Longitude"
value={longitude}
onChange={(val) => setLongitude(val)}
decimalScale={6}
data-testid="location-longitude-input"
/>
<NumberInput
label="Altitude (optional)"
value={altitude}
onChange={(val) => setAltitude(val)}
decimalScale={2}
placeholder="Leave blank to clear"
data-testid="location-altitude-input"
/>
{error && (
<Alert color="red" data-testid="edit-location-error">
{error}
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} data-testid="edit-location-cancel">
Cancel
</Button>
<Button
type="submit"
loading={updateMutation.isPending}
data-testid="edit-location-submit"
>
Save
</Button>
</Group>
</Stack>
</form>
</Modal>
)
}
+130
View File
@@ -0,0 +1,130 @@
/**
* EditPooModal edit non-PK fields of a poo record (M2-T10, reused by T09).
*
* Editable fields: status, latitude, longitude.
* Read-only: timestamp (PK).
*/
import { useState } from 'react'
import {
Modal,
Stack,
TextInput,
NumberInput,
Button,
Group,
Text,
Alert,
} from '@mantine/core'
import { useUpdatePoo } from './hooks'
import type { PooRecord, PooUpdateBody } from './hooks'
export interface EditPooModalProps {
record: PooRecord
onClose: () => void
onSaved: () => void
}
export function EditPooModal({ record, onClose, onSaved }: EditPooModalProps) {
const [status, setStatus] = useState(record.status)
const [latitude, setLatitude] = useState<number | string>(record.latitude)
const [longitude, setLongitude] = useState<number | string>(record.longitude)
const [error, setError] = useState<string | null>(null)
const updateMutation = useUpdatePoo()
function validate(): string | null {
const lat = Number(latitude)
const lng = Number(longitude)
if (isNaN(lat) || lat < -90 || lat > 90) return 'Latitude must be a number between -90 and 90.'
if (isNaN(lng) || lng < -180 || lng > 180)
return 'Longitude must be a number between -180 and 180.'
return null
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
const validationError = validate()
if (validationError) {
setError(validationError)
return
}
const body: PooUpdateBody = {
status: status || undefined,
latitude: Number(latitude),
longitude: Number(longitude),
}
try {
await updateMutation.mutateAsync({ timestamp: record.timestamp, body })
onSaved()
onClose()
} catch {
setError('Failed to save. Please try again.')
}
}
return (
<Modal
opened
onClose={onClose}
title="Edit Poo Record"
size="sm"
data-testid="edit-poo-modal"
>
<form onSubmit={handleSubmit} data-testid="edit-poo-form">
<Stack gap="sm">
{/* PK — read-only */}
<Text size="sm" c="dimmed">
<strong>Timestamp (PK):</strong> {record.timestamp}
</Text>
<TextInput
label="Status"
value={status}
onChange={(e) => setStatus(e.currentTarget.value)}
data-testid="poo-status-input"
/>
<NumberInput
label="Latitude"
value={latitude}
onChange={(val) => setLatitude(val)}
decimalScale={6}
data-testid="poo-latitude-input"
/>
<NumberInput
label="Longitude"
value={longitude}
onChange={(val) => setLongitude(val)}
decimalScale={6}
data-testid="poo-longitude-input"
/>
{error && (
<Alert color="red" data-testid="edit-poo-error">
{error}
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} data-testid="edit-poo-cancel">
Cancel
</Button>
<Button
type="submit"
loading={updateMutation.isPending}
data-testid="edit-poo-submit"
>
Save
</Button>
</Group>
</Stack>
</form>
</Modal>
)
}
+176
View File
@@ -0,0 +1,176 @@
/**
* Real-encoding regression test for M2-T10 (REWORK 1).
*
* Motivation: RecordsPage.test.tsx mocks the entire apiClient module, so
* openapi-fetch's defaultPathSerializer never runs in those tests. That means
* the integration between hooks.ts and the real client cannot be verified there.
*
* This file uses two complementary strategies:
*
* A) Direct serializer test import openapi-fetch's defaultPathSerializer and
* verify that raw PK values (with ':') produce single-encoded URLs (%3A,
* NOT %253A). This is a pure-function test with no network I/O.
*
* B) Live fetch stub create a real openapi-fetch client instance with a
* custom fetch stub, call the same path that hooks.ts calls (with a raw PK),
* and assert the URL the client constructs contains exactly one level of
* encoding. This exercises the full openapi-fetch URL-construction path.
*
* Together these prove:
* 1. openapi-fetch encodes raw ':' correctly (as '%3A', once).
* 2. The path template /api/poo/{timestamp} with a raw timestamp produces
* the right URL and would break if encodeURIComponent were applied first.
*/
import { describe, it, expect, vi, afterEach } from 'vitest'
import createClient, { defaultPathSerializer } from 'openapi-fetch'
import type { paths } from '../api/schema.d.ts'
afterEach(() => {
vi.unstubAllGlobals()
})
// ---------------------------------------------------------------------------
// A) defaultPathSerializer unit tests
// ---------------------------------------------------------------------------
describe('openapi-fetch defaultPathSerializer (raw PK → single-encoded URL)', () => {
it('encodes a poo timestamp with colons exactly once', () => {
const template = '/api/poo/{timestamp}'
const rawTs = '2026-06-12T10:00:00Z'
const result = defaultPathSerializer(template, { timestamp: rawTs })
// Single-encoded colon
expect(result).toContain('%3A')
// Double-encoded colon must NOT appear
expect(result).not.toContain('%253A')
expect(result).toBe('/api/poo/2026-06-12T10%3A00%3A00Z')
})
it('encodes location person+datetime with colons exactly once', () => {
const template = '/api/locations/{person}/{datetime}'
const rawDt = '2026-06-12T09:00:00Z'
const result = defaultPathSerializer(template, { person: 'alice', datetime: rawDt })
expect(result).toContain('%3A')
expect(result).not.toContain('%253A')
expect(result).toBe('/api/locations/alice/2026-06-12T09%3A00%3A00Z')
})
it('pre-encoding a PK before passing it causes double-encoding (%253A)', () => {
// This test documents the BUG that was present before REWORK 1:
// hooks.ts was calling encodeURIComponent(timestamp) before passing to
// the client, so defaultPathSerializer would encode it a second time.
const template = '/api/poo/{timestamp}'
const rawTs = '2026-06-12T10:00:00Z'
const preEncoded = encodeURIComponent(rawTs) // what the old hooks.ts did
const result = defaultPathSerializer(template, { timestamp: preEncoded })
// Double-encoded: '%' → '%25', then '3A' stays → '%253A'
expect(result).toContain('%253A')
// This is WRONG — after fix, hooks must NOT pre-encode.
})
})
// ---------------------------------------------------------------------------
// B) Live fetch-stub test using a real openapi-fetch client instance
// ---------------------------------------------------------------------------
describe('real openapi-fetch client URL construction (fetch-stub)', () => {
it('DELETE /api/poo/{timestamp} with raw PK produces single-encoded URL', async () => {
const capturedUrls: string[] = []
const fakeFetch = vi.fn((_input: RequestInfo | URL) => {
const url =
typeof _input === 'string'
? _input
: _input instanceof URL
? _input.href
: (_input as Request).url
capturedUrls.push(url)
return Promise.resolve(new Response(null, { status: 204 }))
})
// Create a real client with our fake fetch — same config as client.ts
// but with an explicit fetch override so we control the transport.
const testClient = createClient<paths>({
baseUrl: 'http://localhost/',
fetch: fakeFetch as typeof fetch,
})
const rawTs = '2026-06-12T10:00:00Z'
await testClient.DELETE('/api/poo/{timestamp}', {
params: { path: { timestamp: rawTs } },
})
expect(fakeFetch).toHaveBeenCalled()
const url = capturedUrls[0]
expect(url).toBeDefined()
// Single-encoded colon: present
expect(url).toContain('%3A')
// Double-encoded colon: must be absent
expect(url).not.toContain('%253A')
expect(url).toContain('/api/poo/2026-06-12T10%3A00%3A00Z')
})
it('DELETE /api/locations/{person}/{datetime} with raw PK produces single-encoded URL', async () => {
const capturedUrls: string[] = []
const fakeFetch = vi.fn((_input: RequestInfo | URL) => {
const url =
typeof _input === 'string'
? _input
: _input instanceof URL
? _input.href
: (_input as Request).url
capturedUrls.push(url)
return Promise.resolve(new Response(null, { status: 204 }))
})
const testClient = createClient<paths>({
baseUrl: 'http://localhost/',
fetch: fakeFetch as typeof fetch,
})
const rawDt = '2026-06-12T09:00:00Z'
await testClient.DELETE('/api/locations/{person}/{datetime}', {
params: { path: { person: 'alice', datetime: rawDt } },
})
expect(fakeFetch).toHaveBeenCalled()
const url = capturedUrls[0]
expect(url).toBeDefined()
expect(url).toContain('%3A')
expect(url).not.toContain('%253A')
expect(url).toContain('/api/locations/alice/2026-06-12T09%3A00%3A00Z')
})
it('double-encoded PK produces wrong URL — documents the fixed bug', async () => {
// This test shows what the OLD hooks.ts would produce.
// It is intentionally asserting the BAD behavior to document the regression.
const capturedUrls: string[] = []
const fakeFetch = vi.fn((_input: RequestInfo | URL) => {
const url =
typeof _input === 'string'
? _input
: _input instanceof URL
? _input.href
: (_input as Request).url
capturedUrls.push(url)
return Promise.resolve(new Response(null, { status: 204 }))
})
const testClient = createClient<paths>({
baseUrl: 'http://localhost/',
fetch: fakeFetch as typeof fetch,
})
const rawTs = '2026-06-12T10:00:00Z'
// Simulate what the old hooks.ts did: pre-encode before passing to client
const preEncoded = encodeURIComponent(rawTs)
await testClient.DELETE('/api/poo/{timestamp}', {
params: { path: { timestamp: preEncoded } },
})
const url = capturedUrls[0]
// The OLD code would produce double-encoding (%253A), which caused 404 on the backend
expect(url).toContain('%253A')
})
})
+98
View File
@@ -0,0 +1,98 @@
/**
* Reusable mutation hooks for poo and location CRUD (M2-T10, reused by T09).
*
* Contract (orchestrator-decisions.md §13):
* - useUpdatePoo / useDeletePoo PK = timestamp, path /api/poo/{timestamp}
* - useUpdateLocation / useDeleteLocation PK = person+datetime, path /api/locations/{person}/{datetime}
* - Path params are passed as raw strings; openapi-fetch's defaultPathSerializer
* already calls encodeURIComponent once per simple {param} segment.
* Do NOT call encodeURIComponent here that would produce double-encoding.
* - On success each hook invalidates the shared query-key prefix ('poo' or 'locations')
* so both list and map views refresh automatically.
*/
import { useMutation, useQueryClient } from '@tanstack/react-query'
import apiClient from '../api/client'
import type { components } from '../api/schema.d.ts'
// Re-export record types so T09 can import them from one place.
export type PooRecord = components['schemas']['PooRecord']
export type LocationRecord = components['schemas']['LocationRecord']
export type PooUpdateBody = components['schemas']['PooUpdateRequest']
export type LocationUpdateBody = components['schemas']['LocationUpdateRequest']
// ---------------------------------------------------------------------------
// Poo hooks
// ---------------------------------------------------------------------------
/** Update non-PK fields of a single poo record. */
export function useUpdatePoo() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ timestamp, body }: { timestamp: string; body: PooUpdateBody }) =>
apiClient.PATCH('/api/poo/{timestamp}', {
params: { path: { timestamp } },
body,
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['poo'] }),
})
}
/** Delete a single poo record by its PK (timestamp). */
export function useDeletePoo() {
const qc = useQueryClient()
return useMutation({
mutationFn: (timestamp: string) =>
apiClient.DELETE('/api/poo/{timestamp}', {
params: { path: { timestamp } },
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['poo'] }),
})
}
// ---------------------------------------------------------------------------
// Location hooks
// ---------------------------------------------------------------------------
/** Update non-PK fields of a single location record. */
export function useUpdateLocation() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({
person,
datetime,
body,
}: {
person: string
datetime: string
body: LocationUpdateBody
}) =>
apiClient.PATCH('/api/locations/{person}/{datetime}', {
params: {
path: {
person,
datetime,
},
},
body,
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['locations'] }),
})
}
/** Delete a single location record by its composite PK (person + datetime). */
export function useDeleteLocation() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ person, datetime }: { person: string; datetime: string }) =>
apiClient.DELETE('/api/locations/{person}/{datetime}', {
params: {
path: {
person,
datetime,
},
},
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['locations'] }),
})
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Public surface of the records module (M2-T10).
*
* T09 (map) imports from here:
* import { useUpdatePoo, useDeletePoo, useUpdateLocation, useDeleteLocation,
* EditPooModal, EditLocationModal, ConfirmDeleteModal } from '../records'
* import type { PooRecord, LocationRecord } from '../records'
*/
// Hooks
export { useUpdatePoo, useDeletePoo, useUpdateLocation, useDeleteLocation } from './hooks'
// Types
export type { PooRecord, LocationRecord, PooUpdateBody, LocationUpdateBody } from './hooks'
// Modals
export { EditPooModal } from './EditPooModal'
export type { EditPooModalProps } from './EditPooModal'
export { EditLocationModal } from './EditLocationModal'
export type { EditLocationModalProps } from './EditLocationModal'
export { ConfirmDeleteModal } from './ConfirmDeleteModal'
export type { ConfirmDeleteModalProps } from './ConfirmDeleteModal'
+38
View File
@@ -0,0 +1,38 @@
/**
* Vitest global setup file.
* Imports @testing-library/jest-dom to extend vitest matchers with DOM assertions.
*
* Also polyfills browser APIs that jsdom does not implement but Mantine needs:
* - window.matchMedia (Mantine uses it for color-scheme detection)
* - ResizeObserver (Mantine uses it for responsive components)
*/
import '@testing-library/jest-dom'
// ---------------------------------------------------------------------------
// window.matchMedia polyfill (jsdom does not implement this)
// ---------------------------------------------------------------------------
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
})
// ---------------------------------------------------------------------------
// ResizeObserver polyfill (jsdom does not implement this)
// ---------------------------------------------------------------------------
if (typeof ResizeObserver === 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(globalThis as any).ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Shared test utilities wraps components in the providers they need.
*
* Usage:
* import { renderWithProviders } from '../test-utils'
* renderWithProviders(<LoginPage />, { initialPath: '/login' })
*/
import type { ReactNode } from 'react'
import { render } from '@testing-library/react'
import { MantineProvider } from '@mantine/core'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
// ---------------------------------------------------------------------------
// Provider wrapper
// ---------------------------------------------------------------------------
interface RenderOptions {
/** Initial URL path (default: '/'). */
initialPath?: string
/**
* Extra routes to register alongside the component under test.
* Useful for asserting navigation (e.g. render a /home sentinel and check
* that the component navigates there after login).
*/
routes?: Array<{ path: string; element: ReactNode }>
/**
* React-router initial entries (overrides initialPath when provided).
* Use when you need to seed location.state (e.g. from-path for redirect-after-login).
*/
initialEntries?: Array<string | { pathname: string; state?: unknown }>
}
/**
* Render `ui` inside MantineProvider + a fresh QueryClientProvider + MemoryRouter.
* SessionProvider is NOT included tests that need session state should mock
* `GET /api/session` via vi.fn() on the apiClient or use MSW.
*/
export function renderWithProviders(ui: ReactNode, options: RenderOptions = {}) {
const { initialPath = '/', routes = [], initialEntries } = options
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const entries = initialEntries ?? [initialPath]
function Wrapper() {
return (
<MantineProvider>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={entries}>
<Routes>
<Route path={initialPath} element={ui} />
{routes.map(({ path, element }) => (
<Route key={path} path={path} element={element} />
))}
</Routes>
</MemoryRouter>
</QueryClientProvider>
</MantineProvider>
)
}
return render(<Wrapper />)
}
/**
* Create a minimal SessionProvider-less wrapper that just supplies the
* query and router context. Returns the queryClient so tests can prime it.
*/
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"allowArbitraryExtensions": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8000',
'/login': 'http://localhost:8000',
'/logout': 'http://localhost:8000',
'/static': 'http://localhost:8000',
'/docs': 'http://localhost:8000',
'/openapi.json': 'http://localhost:8000',
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-setup.ts'],
},
})
+1439 -145
View File
File diff suppressed because it is too large Load Diff
+1074 -117
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -26,3 +26,8 @@ pythonpath = ["."]
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
[tool.ruff.lint.per-file-ignores]
# Scripts bootstrap sys.path before importing app modules, so their top-level
# app imports legitimately sit below executable setup code.
"scripts/*.py" = ["E402"]
+1 -1
View File
@@ -3,8 +3,8 @@ apscheduler>=3.10,<4.0
argon2-cffi>=25.1,<26.0 argon2-cffi>=25.1,<26.0
fastapi>=0.115,<0.116 fastapi>=0.115,<0.116
httpx>=0.28,<1.0 httpx>=0.28,<1.0
jinja2>=3.1,<4.0
pydantic-settings>=2.6,<3.0 pydantic-settings>=2.6,<3.0
pyotp>=2.9,<3.0
python-multipart>=0.0.12,<1.0 python-multipart>=0.0.12,<1.0
pyyaml>=6.0,<7.0 pyyaml>=6.0,<7.0
sqlalchemy>=2.0,<3.0 sqlalchemy>=2.0,<3.0
+3 -5
View File
@@ -45,14 +45,10 @@ idna==3.11
# via # via
# anyio # anyio
# httpx # httpx
jinja2==3.1.6
# via -r requirements.in
mako==1.3.11 mako==1.3.11
# via alembic # via alembic
markupsafe==3.0.3 markupsafe==3.0.3
# via # via mako
# jinja2
# mako
pycparser==2.23 pycparser==2.23
# via cffi # via cffi
pydantic==2.13.2 pydantic==2.13.2
@@ -63,6 +59,8 @@ pydantic-core==2.46.2
# via pydantic # via pydantic
pydantic-settings==2.13.1 pydantic-settings==2.13.1
# via -r requirements.in # via -r requirements.in
pyotp==2.10.0
# via -r requirements.in
python-dotenv==1.2.2 python-dotenv==1.2.2
# via # via
# pydantic-settings # pydantic-settings

Some files were not shown because too many files have changed in this diff Show More