Compare commits
44 Commits
b359bbe3bf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9da88db221 | |||
| 1a3aaea933 | |||
| bf7fd71a21 | |||
| 962ba26c7c | |||
| da236643f2 | |||
| bd09523e94 | |||
| 53f1245d83 | |||
| 51f712f602 | |||
| f8b1e5fc71 | |||
| a9830c42d8 | |||
| 8aa7316b26 | |||
| 32d93bba2a | |||
| 0d988a9b28 | |||
| ef7ea6b971 | |||
| 6cc6382515 | |||
| ef2bd3c9c5 | |||
| cc2c02a2e2 | |||
| b2e26f0b17 | |||
| 8975acc48b | |||
| 6cfeb2b865 | |||
| dba9e28540 | |||
| 2bc5d6ea9a | |||
| 3ec663e138 | |||
| 048414c5cb | |||
| 9ce3f2a0b8 | |||
| 0fba7cfe11 | |||
| d8303eaa3d | |||
| 8da1f13e60 | |||
| de77019ce3 | |||
| c2b1b7b751 | |||
| 3628ac51e5 | |||
| 1756192270 | |||
| 66ec9979cc | |||
| c1a5d7a425 | |||
| 1e0b235cef | |||
| a337b06c94 | |||
| 1cbe6c46d2 | |||
| 2f634006d2 | |||
| dc624bb7e5 | |||
| af8c602988 | |||
| 0d898e09f2 | |||
| 3d3c2bcc57 | |||
| bc8dd062d5 | |||
| 427a491380 |
@@ -8,3 +8,6 @@ data
|
||||
openapi
|
||||
src
|
||||
|
||||
# Frontend host build artifacts — built inside the node stage, not needed from context
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
|
||||
@@ -4,8 +4,6 @@ APP_NAME=Home Automation Backend (Python)
|
||||
APP_ENV=production
|
||||
APP_HOSTNAME=home-automation.example.com
|
||||
APP_DATABASE_URL=sqlite:////app/data/app.db
|
||||
LOCATION_DATABASE_URL=sqlite:////app/data/locationRecorder.db
|
||||
POO_DATABASE_URL=sqlite:////app/data/pooRecorder.db
|
||||
AUTH_BOOTSTRAP_USERNAME=admin
|
||||
AUTH_BOOTSTRAP_PASSWORD=change-me
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -45,6 +45,14 @@
|
||||
- **Implementer**(便宜模型,用户指定):一次一个任务,严格按任务卡,不扩范围。
|
||||
- **Reviewer**(强模型,用户指定):实现完成后起 Reviewer sub-agent,按任务卡 `Acceptance criteria` + `Reviewer checklist` 复核、**独立重跑校验闸门**,驱动 implementer 返工直到本轮 PASS。
|
||||
|
||||
#### 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、最小上下文,把它当"**外部审计**"而非"确认自己没错"。
|
||||
|
||||
### 校验闸门(每个任务结束都要全绿)
|
||||
|
||||
根目录、激活 `.venv` 后:
|
||||
@@ -56,6 +64,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)。
|
||||
**不过闸门就不算完成**,不得跳过、不得留红给下一轮。
|
||||
|
||||
### 构建上下文完整性(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/` 下产出**中文简报**。该目录**已在 `.gitignore` 忽略**,纯本地、不入库——它是 agent 之间和与人之间的交接载体,不是仓库产物。
|
||||
@@ -97,12 +113,13 @@ python scripts/export_openapi.py && git diff --exit-code openapi/ # 改了路
|
||||
- 每次提交前**自检**:`git log -1 --format=%B` 的输出**不得包含** `Co-authored-by`(大小写不限)。若发现,立即 `git commit --amend` 去掉后再继续。
|
||||
|
||||
### Review 后返工
|
||||
- 返工产生的提交**一律用 fixup**,指向本轮对应的 base commit,**不写新的独立 message**:
|
||||
- **自动化 orchestration 模式内**的 review 返工:**一律用 fixup**,指向本轮对应的 base commit,**不写新的独立 message**:
|
||||
```bash
|
||||
git add -A
|
||||
git commit --fixup=<base-commit-sha>
|
||||
```
|
||||
- 多轮返工就多个 `fixup!` 提交,都指向同一个 base commit。
|
||||
- 多轮返工就多个 `fixup!` 提交,都指向同一个 base commit;收尾时 auto-squash(见下)。
|
||||
- **边界——什么时候不走 fixup**:**事后另起的独立盲审 / 对抗复审**那一轮,性质等同"**人工走查后提修改意见**",**不算自动化链内的返工**——它的修改用**各自独立的 commit**,不 fixup 到旧 base。判据:这轮返工是否在**同一条自动化 implement→review 链**里?是 → `fixup`;是事后另起的独立审计 → 独立 commit。
|
||||
|
||||
### 本轮 / feature 收尾(用户确认收尾后)
|
||||
- 用 **auto-squash** 把所有 `fixup!` 合并进各自目标,保证**一个 feature 一个干净 commit**:
|
||||
@@ -115,6 +132,15 @@ python scripts/export_openapi.py && git diff --exit-code openapi/ # 改了路
|
||||
### 一般约束
|
||||
- commit / push 只在用户要求时进行;push、force-push、开/改 PR 等对外操作先确认。
|
||||
|
||||
## 发版前置走查(打 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 / 对外发布,属对外操作,**先确认**。
|
||||
|
||||
## 数据安全红线(不可违反)
|
||||
|
||||
- 任何脚本 / migration **都不得删除或覆盖用户数据文件**(旧 `.db`、备份、volume)。删除只能是人工、事后、保留归档的独立步骤(见 `docs/design/m1-db-consolidation.md` §6 runbook)。
|
||||
|
||||
+20
-4
@@ -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
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
@@ -11,15 +28,14 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app ./app
|
||||
COPY alembic_app ./alembic_app
|
||||
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 docker ./docker
|
||||
COPY README.md ./
|
||||
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
|
||||
|
||||
ENTRYPOINT ["/app/docker/entrypoint.sh"]
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
当前系统已经包含:
|
||||
|
||||
- FastAPI Web 应用与服务端模板页面
|
||||
- SQLite + SQLAlchemy + Alembic 的三库结构
|
||||
- FastAPI Web 应用(React SPA 前端 + JSON API)
|
||||
- SQLite + SQLAlchemy + Alembic 的单库结构
|
||||
- username/password + server-side session 鉴权
|
||||
- runtime config 页面与 app DB 持久化
|
||||
- public IPv4 monitor、历史持久化与定时检查
|
||||
@@ -23,44 +23,37 @@
|
||||
|
||||
## 当前配置现实
|
||||
|
||||
当前系统仍然是三个独立的 SQLite 数据库文件,而不是单一数据库:
|
||||
当前系统使用单一 SQLite 数据库文件(`app.db`),所有数据表都在其中:
|
||||
|
||||
- `app` 级共享数据使用自己的 DB 文件
|
||||
- `location` 模块使用自己的 DB 文件
|
||||
- `poo` 模块使用自己的 DB 文件
|
||||
- auth(单个 admin 用户、server-side session)
|
||||
- runtime config 持久化(`app_config` 表)
|
||||
- public IPv4 当前状态与变化历史
|
||||
- location 记录(`location` 表)
|
||||
- poo 记录(`poo_records` 表)
|
||||
|
||||
当前阶段明确不借这次重构把这些 DB 合并。配置层已经显式反映这一点:
|
||||
配置层只保留一个数据库环境变量:
|
||||
|
||||
- `APP_DATABASE_URL`
|
||||
- `LOCATION_DATABASE_URL`
|
||||
- `POO_DATABASE_URL`
|
||||
|
||||
目前 auth、`location` 和 `poo` 都已经接到各自独立的数据库文件。
|
||||
`app.db` 不会在应用启动时自动创建,需要先运行:
|
||||
|
||||
其中 `app` 级共享 DB 当前主要用于:
|
||||
```bash
|
||||
python -m scripts.run_migrations
|
||||
```
|
||||
|
||||
- 单个 admin 用户
|
||||
- server-side session
|
||||
- runtime config 持久化
|
||||
- public IPv4 当前状态与变化历史
|
||||
|
||||
这部分现在也使用 Alembic 管理:
|
||||
|
||||
- `app db` 不会在应用启动时自动创建
|
||||
- 需要先运行 `python scripts/app_db_adopt.py`
|
||||
- 这个脚本会创建新 DB 并建好 schema
|
||||
该命令会通过 Alembic 将 `app.db` 初始化或升级到最新 head(含 `location` / `poo_records` 表)。
|
||||
|
||||
## 当前目录
|
||||
|
||||
主要目录如下:
|
||||
|
||||
- `app/`: FastAPI 应用代码
|
||||
- `alembic_app/`: App DB 的 Alembic migration 环境
|
||||
- `alembic_location/`: Location DB 的 Alembic migration 环境
|
||||
- `alembic_poo/`: Poo DB 的 Alembic migration 环境
|
||||
- `app/`: FastAPI 应用代码(包含 JSON API、业务服务、数据模型)
|
||||
- `frontend/`: React SPA 前端(Vite + React + TypeScript + Mantine)
|
||||
- `alembic_app/`: App DB 的 Alembic migration 环境(同时管理 `location` / `poo_records` 表)
|
||||
- `tests/`: pytest 测试
|
||||
- `docs/`: 当前系统说明文档
|
||||
- `scripts/`: 辅助脚本,例如 OpenAPI 导出
|
||||
- `openapi/`: OpenAPI schema 静态产物(`openapi.json` / `openapi.yaml`),纳入版本控制
|
||||
|
||||
## 依赖管理
|
||||
|
||||
@@ -121,31 +114,80 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
启动后可访问:
|
||||
|
||||
- 应用首页:`http://localhost:8000/`
|
||||
- 应用首页(React SPA):`http://localhost:8000/`
|
||||
- 健康检查:`http://localhost:8000/status`
|
||||
- Swagger UI:`http://localhost:8000/docs`
|
||||
- ReDoc:`http://localhost:8000/redoc`
|
||||
|
||||
## 前端 v2(React 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
|
||||
|
||||
当前默认使用 SQLite,并区分三个数据库文件:
|
||||
当前使用单一 SQLite 数据库文件:
|
||||
|
||||
- App DB:`sqlite:///./data/app.db`
|
||||
- Location DB:`sqlite:///./data/locationRecorder.db`
|
||||
- Poo DB:`sqlite:///./data/pooRecorder.db`
|
||||
- 数据目录:`./data/`
|
||||
|
||||
初始化 migration 环境后,可继续添加模型并生成迁移:
|
||||
所有模型(auth / config / public_ip / location / poo)共用同一个 `Base`,均通过单一 Alembic 链管理:
|
||||
|
||||
当前 `app`、`location` 和 `poo` 都已经有各自独立的 Alembic 链路。
|
||||
|
||||
- App Alembic 环境:`alembic_app.ini` + `alembic_app/`
|
||||
- Location Alembic 环境:`alembic_location.ini` + `alembic_location/`
|
||||
- Poo Alembic 环境:`alembic_poo.ini` + `alembic_poo/`
|
||||
- Alembic 环境:`alembic_app.ini` + `alembic_app/`
|
||||
- 统一 migration job:`python -m scripts.run_migrations`
|
||||
- App DB 初始化:`python scripts/app_db_adopt.py`
|
||||
- Location DB 接管 / 初始化:`python scripts/location_db_adopt.py`
|
||||
- Poo DB 接管 / 初始化:`python scripts/poo_db_adopt.py`
|
||||
- App DB 接管 / 初始化:`python scripts/app_db_adopt.py`
|
||||
|
||||
历史 location / poo 数据(旧版本遗留的独立 DB 文件)已通过以下脚本一次性迁移至 `app.db`(幂等,不删除旧文件):
|
||||
|
||||
```bash
|
||||
python -m scripts.migrate_legacy_data
|
||||
```
|
||||
|
||||
## 基础鉴权
|
||||
|
||||
@@ -153,9 +195,9 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
- 认证模型:`username/password`
|
||||
- 会话模型:server-side session + cookie
|
||||
- 当前主要受保护页面:`/config`
|
||||
- 当前公开页面:`/login`
|
||||
- 当前公开 API:现有业务 API 暂未在这一轮统一收口到 auth 下
|
||||
- 当前受保护入口:React SPA(`/` 等客户端路由)调用 `/api/*` JSON 端点
|
||||
- 当前公开页面:`/login`(SPA 登录页)
|
||||
- 当前公开 API:裸 ingestion 端点(`/location/record`、`/poo/record` 等设备调用端点)暂未收口到 session 保护(M3 再做)
|
||||
|
||||
安全实现的当前边界:
|
||||
|
||||
@@ -163,7 +205,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
- session cookie 使用 `HttpOnly`
|
||||
- `Secure` 默认随 `APP_ENV` 切换:非 development 时默认开启
|
||||
- `SameSite=Lax`
|
||||
- 登录表单和登出表单都有基础 CSRF 防护
|
||||
- 写请求(POST/PUT/PATCH/DELETE)需携带 `X-CSRF-Token` header(SameSite=Lax + 自定义 header 纵深防御,无需 per-session token 值比对)
|
||||
|
||||
首次启动时,如果 `APP_DATABASE_URL` 对应的 auth DB 里还没有用户,应用会使用:
|
||||
|
||||
@@ -177,12 +219,14 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。
|
||||
|
||||
当前前端主要有两条页面路径:
|
||||
React SPA 主要页面路由(客户端路由,均由 FastAPI fallback 到 `index.html`):
|
||||
|
||||
- `/login`
|
||||
- `/config`
|
||||
- `/login`:登录页
|
||||
- `/`:首页(地图热力图主视图)
|
||||
- `/config`:配置页(取代原 Jinja `/config`)
|
||||
- `/records`:记录管理列表页
|
||||
|
||||
无论是本地 `host:port` 还是反向代理后的域名访问,登录成功后都使用相对路径跳转到 `/config`。
|
||||
无论是本地 `host:port` 还是反向代理后的域名访问,登录成功后进入 SPA 首页(`/`)。
|
||||
|
||||
## Config 持久化
|
||||
|
||||
@@ -197,7 +241,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
这意味着:
|
||||
|
||||
- location / poo / app DB 地址仍然属于 bootstrap 范畴
|
||||
- app DB 地址(`APP_DATABASE_URL`)仍然属于 bootstrap 范畴
|
||||
- 运行时可编辑配置主要通过 `app_config` 表持久化
|
||||
- token / secret 这类运行时必须可取回的配置,目前允许明文存储在 config 表中
|
||||
- 登录密码仍然单独使用 Argon2 哈希,不走 config 表明文存储
|
||||
@@ -241,8 +285,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
当前系统已经提供最小可用的 SMTP 能力:
|
||||
|
||||
- SMTP 配置可在 `/config` 页面填写并保存到 `app_config`
|
||||
- 可通过 config 页面发送测试邮件
|
||||
- SMTP 配置可在 React SPA `/config` 页面填写并保存到 `app_config`(通过 `PUT /api/config`)
|
||||
- 可通过 config 页面发送测试邮件(`POST /api/config/smtp/test`)
|
||||
- 邮件 `From` 头支持显示名,例如 `Home Automation <sender@example.com>`
|
||||
|
||||
当前 SMTP 配置项包括:
|
||||
@@ -294,18 +338,20 @@ python scripts/export_openapi.py
|
||||
|
||||
当前 Compose 分成两层:
|
||||
|
||||
- `docker-compose.yml`:默认使用 registry image,适合部署 / 生产拉取
|
||||
- `docker-compose.override.yml`:仅为本地开发追加 `build: .`
|
||||
- `docker-compose.yml`:默认使用 registry image,适合部署 / 生产拉取(暴露 8881)
|
||||
- `docker-compose.dev.yml`:本地开发显式叠加层——追加 `build: .`、独立 project /
|
||||
容器名(`-dev` 后缀)、暴露 8001,并把 DB 指向挂载的 `./data` 副本,可与生产栈在同一台机器上并存
|
||||
|
||||
本地开发启动方式:
|
||||
本地开发启动方式(显式叠加 dev 层):
|
||||
|
||||
```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
|
||||
docker compose -f docker-compose.yml pull
|
||||
@@ -318,55 +364,6 @@ docker compose -f docker-compose.yml up -d
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
## Grafana Provisioning
|
||||
|
||||
当前仓库支持通过 Grafana provisioning 自动加载 SQLite datasource 和 repo 内的 dashboard 导出文件。
|
||||
|
||||
需要保留的文件路径如下:
|
||||
|
||||
- `grafana/provisioning/datasources/locationrecorder.yaml`
|
||||
- `grafana/provisioning/datasources/poorecorder.yaml`
|
||||
- `grafana/provisioning/dashboards/provider.yaml`
|
||||
- `grafana/dashboards/locationrecorder.json`
|
||||
- `grafana/dashboards/poorecorder.json`
|
||||
|
||||
这些文件的职责分别是:
|
||||
|
||||
- `grafana/provisioning/datasources/locationrecorder.yaml`:声明 `locationrecorder` SQLite datasource,并指向 `/data/home-automation/locationRecorder.db`
|
||||
- `grafana/provisioning/datasources/poorecorder.yaml`:声明 `poorecorder` SQLite datasource,并指向 `/data/home-automation/pooRecorder.db`
|
||||
- `grafana/provisioning/dashboards/provider.yaml`:告诉 Grafana 从 `/var/lib/grafana/dashboards` 扫描并加载 dashboard JSON
|
||||
- `grafana/dashboards/locationrecorder.json`:location recorder dashboard 导出文件,内容本身不需要在 compose 中改写
|
||||
- `grafana/dashboards/poorecorder.json`:poo recorder dashboard 导出文件,内容本身不需要在 compose 中改写
|
||||
|
||||
当前 `docker-compose.yml` 中,Grafana service 需要挂载以下目录:
|
||||
|
||||
- `./grafana/provisioning -> /etc/grafana/provisioning:ro`
|
||||
- `./grafana/dashboards -> /var/lib/grafana/dashboards:ro`
|
||||
|
||||
同时保留现有 named volume `homeautomation_grafana_storage:/var/lib/grafana` 作为 Grafana 运行态数据存储。
|
||||
|
||||
一键启动前,至少需要以下文件已经存在:
|
||||
|
||||
- `grafana/provisioning/datasources/locationrecorder.yaml`
|
||||
- `grafana/provisioning/datasources/poorecorder.yaml`
|
||||
- `grafana/provisioning/dashboards/provider.yaml`
|
||||
- `grafana/dashboards/locationrecorder.json`
|
||||
- `grafana/dashboards/poorecorder.json`
|
||||
|
||||
启动方式:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
启动后会发生的事情:
|
||||
|
||||
- Grafana 容器会安装 `frser-sqlite-datasource` 插件
|
||||
- Grafana 会读取 `/etc/grafana/provisioning/datasources/` 下的 datasource YAML
|
||||
- Grafana 会读取 `/etc/grafana/provisioning/dashboards/provider.yaml`
|
||||
- Grafana 会从 `/var/lib/grafana/dashboards/` 自动导入两个 dashboard JSON
|
||||
- 现有 Grafana named volume 继续负责保存 Grafana 运行态数据,不会覆盖 repo 内的 dashboard 与 provisioning 文件
|
||||
|
||||
## Container Image CI
|
||||
|
||||
项目提供了一个 release image workflow:
|
||||
@@ -411,9 +408,16 @@ pytest
|
||||
|
||||
当前测试包含:
|
||||
|
||||
- app 基本启动测试
|
||||
- `/status` endpoint 测试
|
||||
- 登录 / session 基础流程测试
|
||||
- app 启动与 `/status` 检查
|
||||
- 登录 / session / 鉴权流程
|
||||
- runtime config 读写
|
||||
- public IPv4 monitor
|
||||
- SMTP 配置与测试发信
|
||||
- location / poo recorder 端点
|
||||
- Home Assistant inbound 集成
|
||||
- TickTick OAuth
|
||||
- 部署与迁移(`run_migrations`)
|
||||
- legacy 数据迁移脚本(`migrate_legacy_data`)
|
||||
|
||||
## OpenAPI 导出
|
||||
|
||||
|
||||
+4
-2
@@ -3,11 +3,13 @@ from logging.config import fileConfig
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.auth_db import AuthBase
|
||||
from app.config import get_settings
|
||||
from app.db import Base
|
||||
from app.models.config import AppConfigEntry # noqa: F401
|
||||
from app.models.auth import AuthSession, AuthUser # noqa: F401
|
||||
from app.models.public_ip import PublicIPHistory, PublicIPState # noqa: F401
|
||||
from app.models.location import Location # noqa: F401
|
||||
from app.models.poo import PooRecord # noqa: F401
|
||||
|
||||
config = context.config
|
||||
|
||||
@@ -19,7 +21,7 @@ configured_url = config.get_main_option("sqlalchemy.url")
|
||||
if not configured_url or configured_url == "sqlite:///./data/app.db":
|
||||
config.set_main_option("sqlalchemy.url", settings.app_database_url)
|
||||
|
||||
target_metadata = AuthBase.metadata
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""merge location and poo_records tables into app chain
|
||||
|
||||
Revision ID: 20260611_06_merge_location_poo_tables
|
||||
Revises: 20260429_05_public_ip_monitor
|
||||
Create Date: 2026-06-11 00:00:01.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260611_06_merge_location_poo_tables"
|
||||
down_revision: Union[str, None] = "20260429_05_public_ip_monitor"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"location",
|
||||
sa.Column("person", sa.Text(), nullable=False),
|
||||
sa.Column("datetime", sa.Text(), nullable=False),
|
||||
sa.Column("latitude", sa.REAL(), nullable=False),
|
||||
sa.Column("longitude", sa.REAL(), nullable=False),
|
||||
sa.Column("altitude", sa.REAL(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("person", "datetime"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"poo_records",
|
||||
sa.Column("timestamp", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.Text(), nullable=False),
|
||||
sa.Column("latitude", sa.REAL(), nullable=False),
|
||||
sa.Column("longitude", sa.REAL(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("timestamp"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("poo_records")
|
||||
op.drop_table("location")
|
||||
@@ -1,37 +0,0 @@
|
||||
[alembic]
|
||||
script_location = alembic_location
|
||||
prepend_sys_path = .
|
||||
path_separator = os
|
||||
sqlalchemy.url = sqlite:///./data/locationRecorder.db
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
@@ -1,2 +0,0 @@
|
||||
This directory contains the Alembic migration environment for the Python rewrite skeleton.
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Location # noqa: F401
|
||||
from app.models.base import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
settings = get_settings()
|
||||
configured_url = config.get_main_option("sqlalchemy.url")
|
||||
if not configured_url or configured_url == "sqlite:///./data/locationRecorder.db":
|
||||
config.set_main_option("sqlalchemy.url", settings.location_database_url)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -1,26 +0,0 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""location baseline
|
||||
|
||||
Revision ID: 20260419_01_location_baseline
|
||||
Revises:
|
||||
Create Date: 2026-04-19 00:00:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260419_01_location_baseline"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"location",
|
||||
sa.Column("person", sa.Text(), nullable=False),
|
||||
sa.Column("datetime", sa.Text(), nullable=False),
|
||||
sa.Column("latitude", sa.Float(), nullable=False),
|
||||
sa.Column("longitude", sa.Float(), nullable=False),
|
||||
sa.Column("altitude", sa.Float(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("person", "datetime"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("location")
|
||||
@@ -1,37 +0,0 @@
|
||||
[alembic]
|
||||
script_location = alembic_poo
|
||||
prepend_sys_path = .
|
||||
path_separator = os
|
||||
sqlalchemy.url = sqlite:///./data/pooRecorder.db
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers = console
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
@@ -1,48 +0,0 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.poo import PooRecord # noqa: F401
|
||||
from app.poo_db import PooBase
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
settings = get_settings()
|
||||
configured_url = config.get_main_option("sqlalchemy.url")
|
||||
if not configured_url or configured_url == "sqlite:///./data/pooRecorder.db":
|
||||
config.set_main_option("sqlalchemy.url", settings.poo_database_url)
|
||||
|
||||
target_metadata = PooBase.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -1,32 +0,0 @@
|
||||
"""poo baseline
|
||||
|
||||
Revision ID: 20260420_01_poo_baseline
|
||||
Revises:
|
||||
Create Date: 2026-04-20 00:00:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260420_01_poo_baseline"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"poo_records",
|
||||
sa.Column("timestamp", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.Text(), nullable=False),
|
||||
sa.Column("latitude", sa.Float(), nullable=False),
|
||||
sa.Column("longitude", sa.Float(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("timestamp"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("poo_records")
|
||||
@@ -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."},
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, 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.services.auth import (
|
||||
AuthPasswordChangeError,
|
||||
AuthenticatedSession,
|
||||
authenticate_user,
|
||||
change_password,
|
||||
create_session,
|
||||
revoke_session,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@router.post("/auth/login", response_model=SessionResponse)
|
||||
def post_login(
|
||||
body: LoginRequest,
|
||||
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.
|
||||
No X-CSRF-Token required (unauthenticated endpoint).
|
||||
"""
|
||||
user = authenticate_user(db, username=body.username, password=body.password)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid username or password",
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -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_auth_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_auth_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_auth_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_auth_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,
|
||||
)
|
||||
@@ -11,7 +11,6 @@ from app.dependencies import (
|
||||
get_app_settings,
|
||||
get_db,
|
||||
get_homeassistant_client,
|
||||
get_poo_db,
|
||||
get_ticktick_client,
|
||||
)
|
||||
from app.integrations.homeassistant import (
|
||||
@@ -36,7 +35,6 @@ INTERNAL_SERVER_ERROR_MESSAGE = "internal server error"
|
||||
async def publish_from_homeassistant(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
poo_db: Session = Depends(get_poo_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client),
|
||||
ticktick_client: TickTickClient = Depends(get_ticktick_client),
|
||||
@@ -49,7 +47,7 @@ async def publish_from_homeassistant(
|
||||
db,
|
||||
envelope,
|
||||
ticktick_client=ticktick_client,
|
||||
poo_session=poo_db,
|
||||
poo_session=db,
|
||||
settings=settings,
|
||||
homeassistant_client=homeassistant_client,
|
||||
)
|
||||
|
||||
@@ -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_auth_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_auth_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_auth_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_auth_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,
|
||||
)
|
||||
@@ -7,7 +7,7 @@ from pydantic import ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.dependencies import get_app_settings, get_homeassistant_client, get_poo_db
|
||||
from app.dependencies import get_app_settings, get_homeassistant_client, get_db
|
||||
from app.integrations.homeassistant import HomeAssistantClient
|
||||
from app.schemas.poo import PooRecordRequest
|
||||
from app.services.poo import publish_latest_poo_status, record_poo
|
||||
@@ -21,7 +21,7 @@ INTERNAL_SERVER_ERROR_MESSAGE = "internal server error"
|
||||
@router.post("/poo/record")
|
||||
async def create_poo_record(
|
||||
request: Request,
|
||||
db: Session = Depends(get_poo_db),
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client),
|
||||
) -> Response:
|
||||
@@ -56,7 +56,7 @@ async def create_poo_record(
|
||||
|
||||
@router.get("/poo/latest")
|
||||
def notify_latest_poo(
|
||||
db: Session = Depends(get_poo_db),
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client),
|
||||
) -> Response:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.dependencies import get_auth_db, get_current_auth_session
|
||||
from app.dependencies import get_db, get_current_auth_session
|
||||
from app.schemas.public_ip import PublicIPCheckResponse
|
||||
from app.config import get_settings
|
||||
from app.services.auth import AuthenticatedSession
|
||||
@@ -12,7 +12,7 @@ router = APIRouter(tags=["public-ip"])
|
||||
|
||||
@router.get("/public-ip/check", response_model=PublicIPCheckResponse)
|
||||
def run_public_ip_check(
|
||||
session: Session = Depends(get_auth_db),
|
||||
session: Session = Depends(get_db),
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> PublicIPCheckResponse:
|
||||
if current_auth is None:
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
|
||||
from app.config import Settings
|
||||
from app.dependencies import (
|
||||
get_app_settings,
|
||||
get_auth_db,
|
||||
get_db,
|
||||
get_current_auth_session,
|
||||
get_ticktick_client,
|
||||
)
|
||||
@@ -39,7 +39,7 @@ def start_ticktick_auth(
|
||||
@router.get("/ticktick/auth/code")
|
||||
def handle_ticktick_auth_code(
|
||||
request: Request,
|
||||
auth_db_session: Session = Depends(get_auth_db),
|
||||
auth_db_session: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
ticktick_client: TickTickClient = Depends(get_ticktick_client),
|
||||
) -> Response:
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
from collections.abc import Generator
|
||||
from functools import lru_cache
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
class AuthBase(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def _build_connect_args(database_url: str) -> dict[str, object]:
|
||||
connect_args: dict[str, object] = {}
|
||||
if database_url.startswith("sqlite"):
|
||||
connect_args["check_same_thread"] = False
|
||||
return connect_args
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _get_auth_engine(database_url: str):
|
||||
return create_engine(database_url, connect_args=_build_connect_args(database_url))
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _get_auth_session_local(database_url: str):
|
||||
engine = _get_auth_engine(database_url)
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
|
||||
|
||||
|
||||
def get_auth_engine():
|
||||
settings = get_settings()
|
||||
return _get_auth_engine(settings.app_database_url)
|
||||
|
||||
|
||||
def get_auth_session_local():
|
||||
settings = get_settings()
|
||||
return _get_auth_session_local(settings.app_database_url)
|
||||
|
||||
|
||||
def reset_auth_db_caches() -> None:
|
||||
_get_auth_session_local.cache_clear()
|
||||
_get_auth_engine.cache_clear()
|
||||
|
||||
|
||||
def get_auth_db_session() -> Generator[Session, None, None]:
|
||||
session_local = get_auth_session_local()
|
||||
session = session_local()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
@@ -12,9 +12,6 @@ class Settings(BaseSettings):
|
||||
app_hostname: str = "localhost:8000"
|
||||
app_database_url: str = "sqlite:///./data/app.db"
|
||||
|
||||
location_database_url: str = "sqlite:///./data/locationRecorder.db"
|
||||
poo_database_url: str = "sqlite:///./data/pooRecorder.db"
|
||||
|
||||
ticktick_client_id: str = ""
|
||||
ticktick_client_secret: str = ""
|
||||
ticktick_token: str = ""
|
||||
@@ -77,21 +74,11 @@ class Settings(BaseSettings):
|
||||
raw_path = database_url[len(prefix) :]
|
||||
return Path(raw_path)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def location_sqlite_path(self) -> Path | None:
|
||||
return self._sqlite_path_from_url(self.location_database_url)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def app_sqlite_path(self) -> Path | None:
|
||||
return self._sqlite_path_from_url(self.app_database_url)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def poo_sqlite_path(self) -> Path | None:
|
||||
return self._sqlite_path_from_url(self.poo_database_url)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def auth_cookie_secure(self) -> bool:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from collections.abc import Generator
|
||||
from functools import lru_cache
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import get_settings
|
||||
@@ -10,18 +12,49 @@ class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
def _build_connect_args(database_url: str) -> dict[str, object]:
|
||||
connect_args: dict[str, object] = {}
|
||||
if database_url.startswith("sqlite"):
|
||||
connect_args["check_same_thread"] = False
|
||||
return connect_args
|
||||
|
||||
connect_args: dict[str, object] = {}
|
||||
if settings.location_database_url.startswith("sqlite"):
|
||||
connect_args["check_same_thread"] = False
|
||||
|
||||
engine = create_engine(settings.location_database_url, connect_args=connect_args)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
|
||||
@lru_cache
|
||||
def _get_engine(database_url: str) -> Engine:
|
||||
engine = create_engine(database_url, connect_args=_build_connect_args(database_url))
|
||||
if database_url.startswith("sqlite"):
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _enable_sqlite_wal(dbapi_connection, _connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.close()
|
||||
|
||||
return engine
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _get_session_local(database_url: str) -> sessionmaker:
|
||||
engine = _get_engine(database_url)
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
|
||||
|
||||
|
||||
def get_engine() -> Engine:
|
||||
return _get_engine(get_settings().app_database_url)
|
||||
|
||||
|
||||
def get_session_local() -> sessionmaker:
|
||||
return _get_session_local(get_settings().app_database_url)
|
||||
|
||||
|
||||
def reset_db_caches() -> None:
|
||||
_get_session_local.cache_clear()
|
||||
_get_engine.cache_clear()
|
||||
|
||||
|
||||
def get_db_session() -> Generator[Session, None, None]:
|
||||
session = SessionLocal()
|
||||
session_local = get_session_local()
|
||||
session = session_local()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
|
||||
+3
-13
@@ -3,30 +3,20 @@ from collections.abc import Generator
|
||||
from fastapi import Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth_db import get_auth_db_session
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import get_db_session
|
||||
from app.integrations.homeassistant import HomeAssistantClient
|
||||
from app.integrations.ticktick import TickTickClient
|
||||
from app.poo_db import get_poo_db_session
|
||||
from app.services.auth import AuthenticatedSession, get_authenticated_session
|
||||
from app.services.config_page import build_runtime_settings
|
||||
|
||||
|
||||
def get_auth_db() -> Generator[Session, None, None]:
|
||||
yield from get_auth_db_session()
|
||||
|
||||
|
||||
def get_app_settings(session: Session = Depends(get_auth_db)) -> Settings:
|
||||
return build_runtime_settings(session, get_settings())
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
yield from get_db_session()
|
||||
|
||||
|
||||
def get_poo_db() -> Generator[Session, None, None]:
|
||||
yield from get_poo_db_session()
|
||||
def get_app_settings(session: Session = Depends(get_db)) -> Settings:
|
||||
return build_runtime_settings(session, get_settings())
|
||||
|
||||
|
||||
def get_homeassistant_client(settings: Settings = Depends(get_app_settings)) -> HomeAssistantClient:
|
||||
@@ -39,7 +29,7 @@ def get_ticktick_client(settings: Settings = Depends(get_app_settings)) -> TickT
|
||||
|
||||
def get_current_auth_session(
|
||||
request: Request,
|
||||
session: Session = Depends(get_auth_db),
|
||||
session: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
) -> AuthenticatedSession | None:
|
||||
raw_token = request.cookies.get(settings.auth_session_cookie_name)
|
||||
|
||||
+60
-37
@@ -1,16 +1,21 @@
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
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 apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import models # noqa: F401
|
||||
from app.api.routes.auth import router as auth_router
|
||||
from app.api.routes import pages, status
|
||||
import app.auth_db as auth_db
|
||||
from app.api.routes.api.config import router as api_config_router
|
||||
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.api.routes.homeassistant import router as homeassistant_router
|
||||
from app.api.routes.location import router as location_router
|
||||
from app.api.routes.poo import router as poo_router
|
||||
@@ -21,12 +26,21 @@ from app.services.auth import AuthBootstrapError, initialize_auth_schema
|
||||
from app.services.config_page import seed_missing_config_from_bootstrap, sync_app_hostname_from_bootstrap
|
||||
from app.services.public_ip import check_public_ipv4_and_notify
|
||||
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
||||
from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db
|
||||
from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_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:
|
||||
session_local = auth_db.get_auth_session_local()
|
||||
session_local = get_session_local()
|
||||
session: Session = session_local()
|
||||
try:
|
||||
check_public_ipv4_and_notify(session, bootstrap_settings=get_settings())
|
||||
@@ -35,7 +49,7 @@ def _run_scheduled_public_ip_check() -> None:
|
||||
|
||||
|
||||
def ensure_auth_db_ready() -> None:
|
||||
session_local = auth_db.get_auth_session_local()
|
||||
session_local = get_session_local()
|
||||
session: Session = session_local()
|
||||
try:
|
||||
validate_app_runtime_db(get_settings().app_database_url)
|
||||
@@ -50,41 +64,16 @@ def ensure_auth_db_ready() -> None:
|
||||
session.close()
|
||||
|
||||
|
||||
def ensure_location_db_ready() -> None:
|
||||
settings = get_settings()
|
||||
if settings.location_sqlite_path is None:
|
||||
return
|
||||
|
||||
try:
|
||||
validate_location_runtime_db(settings.location_database_url)
|
||||
except LocationDatabaseAdoptionError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
|
||||
|
||||
def ensure_poo_db_ready() -> None:
|
||||
settings = get_settings()
|
||||
if settings.poo_sqlite_path is None:
|
||||
return
|
||||
|
||||
try:
|
||||
validate_poo_runtime_db(settings.poo_database_url)
|
||||
except PooDatabaseAdoptionError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
|
||||
|
||||
def ensure_runtime_dirs() -> None:
|
||||
settings = get_settings()
|
||||
for path in (settings.app_sqlite_path, settings.location_sqlite_path, settings.poo_sqlite_path):
|
||||
if path is not None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if settings.app_sqlite_path is not None:
|
||||
settings.app_sqlite_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
ensure_runtime_dirs()
|
||||
ensure_auth_db_ready()
|
||||
ensure_location_db_ready()
|
||||
ensure_poo_db_ready()
|
||||
scheduler = BackgroundScheduler(timezone="UTC")
|
||||
scheduler.add_job(
|
||||
_run_scheduled_public_ip_check,
|
||||
@@ -116,13 +105,47 @@ def create_app() -> FastAPI:
|
||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
|
||||
app.include_router(status.router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(pages.router)
|
||||
app.include_router(api_config_router)
|
||||
app.include_router(api_data_router)
|
||||
app.include_router(api_session_router)
|
||||
app.include_router(homeassistant_router)
|
||||
app.include_router(location_router)
|
||||
app.include_router(poo_router)
|
||||
app.include_router(public_ip_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
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from app.models.auth import AuthSession, AuthUser
|
||||
from app.models.config import AppConfigEntry
|
||||
from app.models.location import Location
|
||||
from app.models.poo import PooRecord
|
||||
from app.models.public_ip import PublicIPHistory, PublicIPState
|
||||
|
||||
__all__ = [
|
||||
@@ -10,6 +11,7 @@ __all__ = [
|
||||
"AuthSession",
|
||||
"AuthUser",
|
||||
"Location",
|
||||
"PooRecord",
|
||||
"PublicIPHistory",
|
||||
"PublicIPState",
|
||||
]
|
||||
|
||||
+3
-3
@@ -3,10 +3,10 @@ from datetime import datetime
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.auth_db import AuthBase
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class AuthUser(AuthBase):
|
||||
class AuthUser(Base):
|
||||
__tablename__ = "auth_users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
@@ -19,7 +19,7 @@ class AuthUser(AuthBase):
|
||||
sessions: Mapped[list["AuthSession"]] = relationship(back_populates="user")
|
||||
|
||||
|
||||
class AuthSession(AuthBase):
|
||||
class AuthSession(Base):
|
||||
__tablename__ = "auth_sessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
from app.db import Base
|
||||
|
||||
__all__ = ["Base"]
|
||||
|
||||
@@ -3,10 +3,10 @@ from datetime import datetime
|
||||
from sqlalchemy import DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.auth_db import AuthBase
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class AppConfigEntry(AuthBase):
|
||||
class AppConfigEntry(Base):
|
||||
__tablename__ = "app_config"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
from sqlalchemy import Float, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.poo_db import PooBase
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class PooRecord(PooBase):
|
||||
class PooRecord(Base):
|
||||
__tablename__ = "poo_records"
|
||||
|
||||
timestamp: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
|
||||
@@ -3,10 +3,10 @@ from datetime import datetime
|
||||
from sqlalchemy import DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.auth_db import AuthBase
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class PublicIPState(AuthBase):
|
||||
class PublicIPState(Base):
|
||||
__tablename__ = "public_ip_state"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
@@ -20,7 +20,7 @@ class PublicIPState(AuthBase):
|
||||
last_provider: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
|
||||
class PublicIPHistory(AuthBase):
|
||||
class PublicIPHistory(Base):
|
||||
__tablename__ = "public_ip_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
class PooBase(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
connect_args: dict[str, object] = {}
|
||||
if settings.poo_database_url.startswith("sqlite"):
|
||||
connect_args["check_same_thread"] = False
|
||||
|
||||
poo_engine = create_engine(settings.poo_database_url, connect_args=connect_args)
|
||||
PooSessionLocal = sessionmaker(bind=poo_engine, autoflush=False, autocommit=False, class_=Session)
|
||||
|
||||
|
||||
def get_poo_db_session() -> Generator[Session, None, None]:
|
||||
session = PooSessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -0,0 +1,24 @@
|
||||
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
|
||||
|
||||
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
confirm_password: str
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth_db import reset_auth_db_caches
|
||||
from app.db import reset_db_caches
|
||||
from app.config import Settings, get_settings
|
||||
from app.models.config import AppConfigEntry
|
||||
|
||||
@@ -127,7 +127,7 @@ def sync_app_hostname_from_bootstrap(session: Session, bootstrap_settings: Setti
|
||||
current_values["APP_HOSTNAME"] = bootstrap_hostname
|
||||
_persist_config_values(session, current_values)
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
reset_db_caches()
|
||||
|
||||
|
||||
def build_runtime_settings(session: Session, bootstrap_settings: Settings) -> Settings:
|
||||
@@ -184,7 +184,7 @@ def save_config_updates(session: Session, form_data: dict[str, str], bootstrap_s
|
||||
_validate_config_values(merged_values, bootstrap_settings)
|
||||
_persist_config_values(session, merged_values)
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
reset_db_caches()
|
||||
|
||||
|
||||
def save_config_value(
|
||||
@@ -199,7 +199,7 @@ def save_config_value(
|
||||
_validate_config_values(current_values, bootstrap_settings)
|
||||
_persist_config_values(session, current_values)
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
reset_db_caches()
|
||||
|
||||
|
||||
def is_ticktick_oauth_ready(settings: Settings) -> bool:
|
||||
@@ -260,8 +260,6 @@ def _settings_payload(settings: Settings) -> dict[str, Any]:
|
||||
"app_debug": settings.app_debug,
|
||||
"app_hostname": settings.app_hostname,
|
||||
"app_database_url": settings.app_database_url,
|
||||
"location_database_url": settings.location_database_url,
|
||||
"poo_database_url": settings.poo_database_url,
|
||||
"ticktick_client_id": settings.ticktick_client_id,
|
||||
"ticktick_client_secret": settings.ticktick_client_secret,
|
||||
"ticktick_token": settings.ticktick_token,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import insert
|
||||
from sqlalchemy import delete, insert, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.location import Location
|
||||
@@ -40,3 +40,58 @@ def record_location(session: Session, payload: LocationRecordRequest) -> None:
|
||||
)
|
||||
session.execute(stmt)
|
||||
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
|
||||
|
||||
+48
-1
@@ -4,7 +4,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
|
||||
from sqlalchemy import desc, insert, select
|
||||
from sqlalchemy import delete, desc, insert, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
@@ -74,6 +74,53 @@ def record_poo(
|
||||
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:
|
||||
stmt = select(PooRecord).order_by(desc(PooRecord.timestamp)).limit(1)
|
||||
record = session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -53,14 +53,10 @@ idna==3.11
|
||||
# httpx
|
||||
iniconfig==2.3.0
|
||||
# via pytest
|
||||
jinja2==3.1.6
|
||||
# via -r requirements.in
|
||||
mako==1.3.11
|
||||
# via alembic
|
||||
markupsafe==3.0.3
|
||||
# via
|
||||
# jinja2
|
||||
# mako
|
||||
# via mako
|
||||
packaging==26.1
|
||||
# via
|
||||
# build
|
||||
|
||||
@@ -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 8001 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:8001:8000"
|
||||
environment:
|
||||
APP_DATABASE_URL: "sqlite:////app/data/app.db"
|
||||
@@ -1,6 +0,0 @@
|
||||
services:
|
||||
migration:
|
||||
build: .
|
||||
|
||||
app:
|
||||
build: .
|
||||
@@ -25,22 +25,3 @@ services:
|
||||
- ./data:/app/data
|
||||
- ./.env:/app/.env:ro
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: home-automation-grafana
|
||||
depends_on:
|
||||
- app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "10.238.75.70:8882:3000"
|
||||
environment:
|
||||
GF_PLUGINS_PREINSTALL: frser-sqlite-datasource
|
||||
volumes:
|
||||
- ./data:/data/home-automation:ro
|
||||
- ./grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
- homeautomation_grafana_storage:/var/lib/grafana
|
||||
|
||||
volumes:
|
||||
homeautomation_grafana_storage:
|
||||
name: homeautomation_grafana_storage
|
||||
|
||||
@@ -23,21 +23,17 @@
|
||||
- 基础路由注册
|
||||
- `config.py`
|
||||
- 环境变量驱动的 settings
|
||||
- `auth_db.py`
|
||||
- app 级共享 auth 数据库
|
||||
- `db.py`
|
||||
- SQLAlchemy engine / session / Base
|
||||
- 统一数据层:一个 `Base`、一个绑定 `app_database_url` 的 cached engine(SQLite WAL)、`get_engine` / `get_session_local` / `reset_db_caches` / `get_db_session`
|
||||
- `dependencies.py`
|
||||
- 通用依赖注入
|
||||
- `api/`
|
||||
- HTTP routes
|
||||
- 当前已迁入 `/login`、`/logout`、`/admin`
|
||||
- 当前已迁入 `GET /public-ip/check`
|
||||
- 当前已迁入 `POST /homeassistant/publish` 第一版入口
|
||||
- 当前已迁入 `POST /poo/record` 与 `GET /poo/latest`
|
||||
- `api/routes/api/`:JSON API(`/api/*` 前缀),供 React SPA 调用:会话/鉴权、配置读写、数据查询、记录 CRUD
|
||||
- 裸 ingestion 端点:`GET /public-ip/check`、`POST /homeassistant/publish`、`POST /poo/record`、`GET /poo/latest`、TickTick OAuth 等
|
||||
- `models/`
|
||||
- SQLAlchemy models
|
||||
- 当前 `auth`、`location` 与 `poo` 使用各自独立的数据库 base
|
||||
- 所有模型(auth / config / public_ip / location / poo)共用同一个 `Base`,均落在单一 `app.db` 中
|
||||
- `schemas/`
|
||||
- Pydantic schemas
|
||||
- `services/`
|
||||
@@ -48,22 +44,12 @@
|
||||
- `integrations/`
|
||||
- 外部系统适配层
|
||||
- 当前已迁入 Home Assistant outbound adapter
|
||||
- `templates/`
|
||||
- Jinja2 模板
|
||||
- `static/`
|
||||
- 极简静态资源
|
||||
|
||||
### `alembic_location/`
|
||||
|
||||
Location DB 的 migration 基础设施。
|
||||
|
||||
### `alembic_app/`
|
||||
|
||||
App DB 的 migration 基础设施。
|
||||
|
||||
### `alembic_poo/`
|
||||
|
||||
Poo DB 的 migration 基础设施。
|
||||
App DB 的唯一 Alembic migration 链,同时管理 `location` / `poo_records` 表。M1 将三个独立 DB 合并进 `app.db` 后,`alembic_location/` 与 `alembic_poo/` 已退役,全部由此链统一管理。
|
||||
|
||||
### `tests/`
|
||||
|
||||
@@ -73,15 +59,26 @@ pytest 测试目录。后续可以在这里自然扩展:
|
||||
- mock 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/`
|
||||
|
||||
辅助脚本目录。当前包含 OpenAPI 导出脚本。
|
||||
辅助脚本目录。当前包含 OpenAPI 导出脚本(`export_openapi.py`)与数据层辅助脚本。
|
||||
|
||||
### `openapi/`
|
||||
|
||||
OpenAPI schema 静态产物(`openapi.json` / `openapi.yaml`),由 `python scripts/export_openapi.py` 生成,纳入版本控制。前端 codegen 以此为契约源。
|
||||
|
||||
## 当前约束
|
||||
|
||||
- 当前只搭骨架,不迁业务逻辑
|
||||
- 当前数据库继续使用 SQLite
|
||||
- 当前不引入前后端分离
|
||||
- ~~当前不引入前后端分离~~ **已退役(M2)**:现为 React SPA + JSON `/api` 层,由 FastAPI 同源托管
|
||||
- 当前不设计 Notion 模块
|
||||
- 当前通知能力仍保持极小范围,不引入独立通知中心或多渠道抽象
|
||||
|
||||
|
||||
@@ -27,15 +27,16 @@
|
||||
### 3.2 鉴权:复用 session cookie + SPA 版 CSRF
|
||||
|
||||
- 继续用现有 **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**。
|
||||
|
||||
### 3.3 前端工程
|
||||
|
||||
- `frontend/`:**Vite + React + TypeScript**。
|
||||
- API client:由后端 `openapi/openapi.json` **自动生成** TS 类型与请求函数(如 `openapi-typescript` + 轻量 fetch 封装,或同类工具)。生成物入库或在 build 时生成(见 T06 决策)。
|
||||
- 可视化:地图 + 热力图(location 轨迹 / poo 点位)。建议 **MapLibre GL 或 Leaflet + heatmap 插件**(最终选型见 §5 决策)。
|
||||
- 状态/数据请求:轻量即可(如 TanStack Query),不引入重型框架。
|
||||
- 组件库:**Mantine**(已定;批电池齐、TS 优先、视觉中性,最贴近此前 Vue 侧 Naive UI 的用法)。
|
||||
- API client:由后端 `openapi/openapi.json` **自动生成** TS 类型与请求函数(如 `openapi-typescript` + 轻量 fetch 封装)。**生成物入库** + `npm run codegen` + CI 校验"生成物与 openapi 同步"(已定)。fetch 封装统一带 cookie、写请求注入自定义 CSRF header、401 跳登录。
|
||||
- 可视化:**Leaflet**(已定)—— `react-leaflet` + `leaflet.heat`(热力图,**头号功能**)+ `leaflet.markercluster`(点多时聚合)+ OSM 栅格瓦片(零 key)。**地图封在一个自包含组件后面**(如 `<RecordsMap points mode onSelect>`,全应用只此处 import leaflet),数据获取/时间窗 state 在外面;这样将来若要换 **MapLibre GL** 是被隔离的局部重写,不波及其它。
|
||||
- 状态/数据请求:轻量即可(**TanStack Query**,已定),不引入重型框架。
|
||||
|
||||
### 3.4 构建与部署
|
||||
|
||||
@@ -65,14 +66,31 @@
|
||||
|
||||
> 记录 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. 任务依赖图
|
||||
|
||||
@@ -104,7 +122,7 @@
|
||||
> 后端任务沿用 M1 的校验闸门(`pytest` / `ruff` / `export_openapi`)。前端任务的闸门见 §8。
|
||||
|
||||
### M2-T01 — config JSON API
|
||||
- **Status**: `todo` · **Depends**: none(M1 完成后)
|
||||
- **Status**: `done` · **Depends**: none(M1 完成后)
|
||||
- **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`
|
||||
- **Steps**: 用 `build_config_sections`/`save_config_updates` 包出 `GET/PUT /api/config`;session 保护;secret 不回显、留空保留旧值语义照搬。
|
||||
@@ -116,7 +134,7 @@
|
||||
- **Reviewer**: 复用了 service 而非复制逻辑;CSRF 校验存在;secret 不泄漏到响应或 OpenAPI 示例。
|
||||
|
||||
### M2-T02 — session / auth JSON API
|
||||
- **Status**: `todo` · **Depends**: none
|
||||
- **Status**: `done` · **Depends**: none
|
||||
- **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`
|
||||
- **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,不明文。
|
||||
|
||||
### M2-T03 — 数据读取 API(locations / 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`
|
||||
- **Steps**: `GET /api/locations`(时间范围 + 分页)、`GET /api/poo`(分页)、`GET /api/public-ip`(state + history);session 保护;查询参数有上限防全表导出。
|
||||
- **Acceptance**:
|
||||
@@ -139,7 +157,7 @@
|
||||
- **Reviewer**: 查询走索引/PK,无 N+1;时间过滤边界正确。
|
||||
|
||||
### 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`
|
||||
- **Steps**: `PATCH`/`DELETE` location(PK person+datetime)与 poo(PK timestamp);session + CSRF 保护;PK 路径参数 URL 解码;删除是**硬删单行**(不是清表)。
|
||||
- **Acceptance**:
|
||||
@@ -151,7 +169,7 @@
|
||||
- **Reviewer**: 删除限定单 PK;编辑校验输入;ingestion 裸端点未被顺手加保护或改动。
|
||||
|
||||
### 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`
|
||||
- **Steps**: `POST /api/config/smtp/test` 复用 `send_smtp_test_email`,返回结构化结果(success / config-error / failed)。
|
||||
- **Acceptance**:
|
||||
@@ -159,7 +177,7 @@
|
||||
- [ ] 校验闸门全绿。
|
||||
|
||||
### M2-T06 — 前端 scaffold + OpenAPI codegen `[structural]`
|
||||
- **Status**: `todo` · **Depends**: M2-T01..T05(OpenAPI 已稳定)
|
||||
- **Status**: `done` · **Depends**: M2-T01..T05(OpenAPI 已稳定)
|
||||
- **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` 脚本
|
||||
- **Steps**: 初始化 Vite React-TS;接 `openapi/openapi.json` 生成类型;写一个最小 App 壳 + 受保护路由骨架;fetch 封装统一带 cookie、写请求注入 CSRF header、401 跳登录。
|
||||
@@ -170,24 +188,24 @@
|
||||
- **Reviewer**: client 全部基于生成类型;CSRF/cookie/401 处理在统一封装层;无手写、与契约不符的请求类型。
|
||||
|
||||
### M2-T07 — 鉴权 UI(登录 / 会话引导 / 改密)
|
||||
- **Status**: `todo` · **Depends**: M2-T06
|
||||
- **Status**: `done` · **Depends**: M2-T06
|
||||
- **Acceptance**: 登录成功进受保护区;未登录访问受保护路由跳登录;强制改密流程可走完;`build/lint/typecheck/test` 全绿。
|
||||
|
||||
### M2-T08 — 配置 UI(取代 Jinja config 页)
|
||||
- **Status**: `todo` · **Depends**: M2-T06
|
||||
- **Status**: `done` · **Depends**: M2-T06
|
||||
- **Acceptance**: 能读/存所有现有配置 section;secret 不回显、留空保留;SMTP 测试按钮反映三态;前端闸门全绿。
|
||||
|
||||
### M2-T09 — 数据可视化 UI(地图 + 热力图)
|
||||
- **Status**: `todo` · **Depends**: M2-T06(数据来自 T03)
|
||||
- **Context**: 接管 Grafana 原职责:location 轨迹/热力图、poo 点位。
|
||||
- **Acceptance**: 地图渲染 location/poo 点;热力图层可切换;时间范围筛选生效;前端闸门全绿。
|
||||
### M2-T09 — 数据可视化 UI(热力图为主的地图)
|
||||
- **Status**: `done` · **Depends**: M2-T06(数据来自 T03)
|
||||
- **Context**: 接管 Grafana 原职责,且**首页主视图就是这张地图**。优先级:**① 热力图(最重要)② 时间范围选择器(必须)③ 散点点位(辅助,主要服务编辑/删除)**。location:去过哪的密度;poo:狗最爱在哪拉。
|
||||
- **Acceptance**: 首页渲染热力图(location / poo);**时间范围选择器生效、只取窗口内数据**(不拉全量);散点层可切换、点选某点可进入编辑/删除(接 T10/T04);location 点多时聚合;响应式(手机浏览器可用);前端闸门全绿。
|
||||
|
||||
### M2-T10 — 记录管理 UI(按需展示 + 增删改)
|
||||
- **Status**: `todo` · **Depends**: M2-T06(CRUD 来自 T04)
|
||||
- **Status**: `done` · **Depends**: M2-T06(CRUD 来自 T04)
|
||||
- **Acceptance**: 列表分页展示 poo/location;可编辑、可删除单条并即时刷新;删除有二次确认;前端闸门全绿。
|
||||
|
||||
### 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 测试)
|
||||
- **Acceptance**:
|
||||
- [ ] `/config` 等路径返回 SPA(`index.html`),`/api/*` 不被 fallback 吞掉,`/static`/资源正常。
|
||||
@@ -196,7 +214,7 @@
|
||||
- **Reviewer**: fallback 不拦截 `/api`、`/docs`、`/openapi.json`、静态资源;未登录访问 API 仍 401(不是被 SPA 壳吞掉)。
|
||||
|
||||
### 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`(镜像断言更新)
|
||||
- **Acceptance**:
|
||||
- [ ] 镜像构建成功且运行镜像不含 node 运行时。
|
||||
@@ -204,7 +222,7 @@
|
||||
- [ ] 校验闸门全绿。
|
||||
|
||||
### M2-T13 — 文档 + OpenAPI 收尾
|
||||
- **Status**: `todo` · **Depends**: M2-T12
|
||||
- **Status**: `done` · **Depends**: M2-T12
|
||||
- **Acceptance**: README 增"前端 v2"段(开发/构建说明);architecture 退役"不前后端分离"约束;roadmap 勾选 M2;`openapi/` 已同步入库。
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Future Ideas / Backlog(暂无 Milestone)
|
||||
|
||||
记录尚未排期的想法。等某条成形、值得集中推进时,再升级为 `docs/roadmap.md` 里的 milestone 并展开成 `docs/design/` 任务卡。**这里只是备忘,不是承诺。**
|
||||
|
||||
> 项目定位:**个人自用、针对自家场景特化,不开源**。因此设计可按单用户 / 自家需求简化,不必为通用性、多租户、对外发布做过度抽象。
|
||||
|
||||
## 数据与存储
|
||||
- 增加更多数据类型 / 来源(持续扩展)。
|
||||
- 针对**需要长期保存**的数据,考虑更合适的存储方案(当前全 SQLite;长期 / 大量数据可能需要更强的数据库)。
|
||||
- 把 **Home Assistant 接收到的数据**纳入本系统做持久化 / 展示。
|
||||
|
||||
## 集成
|
||||
- **MQTT**:让后端作为一个 MQTT client,双向收发数据。
|
||||
|
||||
## 前端 / 移动端
|
||||
- **PWA**(**近期、可能并入 M2 或单独小里程碑**):在 React Native(M3)之前,用 PWA 把 web SPA 包装成"准手机 App"——可安装到桌面、响应式、离线壳。
|
||||
- 影响当下设计:**M2 的 UI 从一开始就按移动端布局考虑**(响应式 + 合理的参数显示),为之后加 PWA 铺路。
|
||||
|
||||
## 备注
|
||||
- 以上为临时记录(讨论 M2 范围时随手想到),后续可增删、重排优先级。
|
||||
+57
-5
@@ -34,15 +34,15 @@
|
||||
|
||||
| 里程碑 | 主题 | 一句话 |
|
||||
| --- | --- | --- |
|
||||
| **M1** | 单库化地基 | 把三库合并成单一 `app.db`,清理散落数据层,删掉 Grafana |
|
||||
| **M2** | 前端 v2 | React SPA 取代 Jinja,承载 config + 可视化 + 记录增删改 |
|
||||
| **M1** ✅ | 单库化地基 | 把三库合并成单一 `app.db`,清理散落数据层,删掉 Grafana |
|
||||
| **M2** ✅ | 前端 v2 | React SPA 取代 Jinja,承载 config + 可视化 + 记录增删改 |
|
||||
| **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 |
|
||||
|
||||
排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上,否则就是在准备推倒的旧数据层上盖新楼、之后回头返工。
|
||||
|
||||
---
|
||||
|
||||
## M1 — 单库化地基
|
||||
## M1 — 单库化地基(✅ 已完成)
|
||||
|
||||
### 目标
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
|
||||
---
|
||||
|
||||
## M2 — 前端 v2(React SPA)
|
||||
## M2 — 前端 v2(React SPA)✅ 已完成
|
||||
|
||||
### 目标
|
||||
|
||||
@@ -125,9 +125,11 @@
|
||||
|
||||
### 鉴权边界(与 M3 衔接)
|
||||
|
||||
- 现在那个“裸 API 记小狗日志”的 ingestion 端点(设备 / 脚本调用,非浏览器)**维持现状到 M3**。
|
||||
- 现在那个”裸 API 记小狗日志”的 ingestion 端点(设备 / 脚本调用,非浏览器)**维持现状到 M3**。
|
||||
- M2 新增的、浏览器调用的 CRUD 端点,用 session 保护即可,本步不引入 token。
|
||||
|
||||
> **M2 已完成**(M2-T01 至 M2-T13 全部 done)。Jinja 模板已移除,React SPA 同源托管,多阶段 Docker 构建通过,所有校验闸门绿。
|
||||
|
||||
---
|
||||
|
||||
## M3 — 开放与移动端(远期试水)
|
||||
@@ -146,3 +148,53 @@
|
||||
|
||||
- 移动端是这一阶段最远期、最不确定的部分。
|
||||
- token 主要是移动端的前置条件;Web 端 React 用现有 session cookie 即可,不需要为它提前引入 token。
|
||||
|
||||
## 下一阶段:已确定要做(尚未拆解为任务卡)
|
||||
|
||||
> 这些是 M2 之后**已经定下来要做**的方向——区别于下面的 Future Ideas(仅备忘、未必做)。这里只记到 roadmap 粒度:确定**做什么、为什么**;具体排期、依赖与原子任务,等动手时再展开成 `docs/design/` 的任务卡。**先后顺序未定**,部分项(如 MQTT)时间点灵活,可提前也可靠后。
|
||||
|
||||
### 1. TOTP 二次验证(Dashboard 加固)
|
||||
|
||||
**动机**:M2 之后多了一个 Web Dashboard。它虽有单 admin 密码保护,但**大概率会暴露在公网**上,只靠密码这一层不够。给登录再叠一层 **TOTP(基于时间的一次性密码,RFC 6238)** 作为第二因子,做纵深防御。
|
||||
|
||||
**范围(粗略,待细化)**:
|
||||
|
||||
- 在现有单 admin(Argon2 + server-side session)登录之上,叠加 TOTP 第二步:密码校验通过后再验 6 位动态码,通过才发 session cookie。
|
||||
- 首次启用时生成 TOTP secret,给出可导入 Authenticator 的二维码 / 可手输密钥;同时生成一组一次性**恢复码(recovery codes)**。
|
||||
|
||||
**运维 / 命令行要求(关键,实现时必须满足)**:
|
||||
|
||||
1. **忘记密码**:不需要任何 Web 端“找回密码”流程——直接在命令行里重置 admin 密码即可(沿用现有 CLI 思路)。
|
||||
2. **TOTP 重置 / 恢复**:必须提供**命令行重置入口**。要覆盖最坏情况——**连恢复码(restore key)都丢了**,也能纯靠 CLI 把 TOTP 关掉 / 重新发放新的 secret,从而恢复登录。即:**CLI 是不依赖任何已存恢复凭据的最终逃生通道**,不能出现“密钥丢了就彻底锁死”的死角。
|
||||
|
||||
### 2. 前端优化
|
||||
|
||||
**动机**:M2 的 React SPA 先把功能跑通,性能 / 体验层面的打磨还没做。这一项**确定要做,但具体优化什么还没定**。
|
||||
|
||||
**范围(待定)**:方向先留空,想清楚再细化。可能的候选(仅占位、非承诺):打包体积与代码分割(M2 构建已提示存在 > 500 kB 的单 chunk)、首屏加载、热力图 / 地图的渲染性能、移动端适配、可访问性等。等确定具体目标后再拆任务卡。
|
||||
|
||||
### 3. MQTT 与 IoT 集成
|
||||
|
||||
**动机**:把这个后端接入家里的 IoT 设备生态,用 **MQTT** 作为设备 ↔ 后端的消息通道。属于**确定的实现方向**,时间点灵活——可以放到后面,也可以提前先做一部分。
|
||||
|
||||
**范围(粗略,待细化)**:
|
||||
|
||||
- 引入 MQTT(接入既有 broker 或自带一个),后端作为订阅 / 发布方与设备互通。
|
||||
- 与现有模块(Home Assistant in/out、location / poo recorder 等)如何衔接、哪些数据走 MQTT,待细化。
|
||||
- 设备侧鉴权 / 安全边界另议(可能与下面第 4 条的 token 共用一套凭据)。
|
||||
|
||||
### 4. 设置页生成 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/` 的任务卡。
|
||||
|
||||
_(暂无条目:原 TOTP 已确定要做,已上移到「下一阶段」。)_
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-ssr/
|
||||
*.local
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -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 T07–T10.
|
||||
|
||||
## 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 T07–T10 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.
|
||||
@@ -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: '^_' },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
Generated
+7289
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+1286
File diff suppressed because it is too large
Load Diff
@@ -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}</>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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:
|
||||
'© <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:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
Vendored
+40
@@ -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 _
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Tests for LoginPage (M2-T07).
|
||||
*
|
||||
* 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.
|
||||
* 2. Successful login → 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.
|
||||
*/
|
||||
|
||||
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 })
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it('navigates to "/" on successful login', async () => {
|
||||
// Simulate a successful POST /api/auth/login response
|
||||
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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('shows inline error on 401 and does NOT navigate', async () => {
|
||||
// Simulate 401: openapi-fetch returns { data: undefined, response: { status: 401 } }
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
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,
|
||||
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')
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* LoginPage — real login form (M2-T07).
|
||||
*
|
||||
* Behaviours:
|
||||
* - Renders a Mantine form with username + password fields.
|
||||
* - 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 (bad credentials) → show an inline error without leaking the password.
|
||||
* - Already-authenticated users visiting /login → redirect to '/'.
|
||||
*/
|
||||
|
||||
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,
|
||||
} from '@mantine/core'
|
||||
import { useSession } from '../auth/SessionProvider'
|
||||
import apiClient from '../api/client'
|
||||
import { setCsrfToken } from '../api/csrf'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface LocationState {
|
||||
from?: { pathname: string }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 [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 />
|
||||
}
|
||||
|
||||
async function handleSubmit(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) {
|
||||
// Bad credentials — do not leak the password in the message.
|
||||
setError('Incorrect username or password.')
|
||||
return
|
||||
}
|
||||
|
||||
// Success: store the CSRF token returned by login (same shape as session response).
|
||||
if (res.data.csrf_token) {
|
||||
setCsrfToken(res.data.csrf_token)
|
||||
}
|
||||
|
||||
// Refresh session state: invalidate the ['session'] query so SessionProvider
|
||||
// picks up the new authenticated state (which may include force_password_change).
|
||||
await queryClient.invalidateQueries({ queryKey: ['session'] })
|
||||
|
||||
// Navigate to the originally-requested route or home.
|
||||
const from = (location.state as LocationState)?.from?.pathname ?? '/'
|
||||
navigate(from, { replace: true })
|
||||
} catch {
|
||||
// Any unexpected error (network, 5xx, etc.)
|
||||
setError('Login failed. 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="lg" ta="center">
|
||||
Sign In
|
||||
</Title>
|
||||
|
||||
{error && (
|
||||
<Alert color="red" mb="md" role="alert" data-testid="login-error">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} 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>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'] }),
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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() {}
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user