diff --git a/docs/plans/2026-03-04-ctms-enterprise-ui-design.md b/docs/plans/2026-03-04-ctms-enterprise-ui-design.md
new file mode 100644
index 00000000..241a54cb
--- /dev/null
+++ b/docs/plans/2026-03-04-ctms-enterprise-ui-design.md
@@ -0,0 +1,152 @@
+# CTMS 企业级 UI 改造设计方案(第一阶段)
+
+## 1. 目标与边界
+
+### 1.1 North Star(1-2 周)
+将 CTMS 从“功能可用”升级为“管理层一眼认可的企业级系统”,聚焦:
+
+1. 视觉统一:10 个核心页面采用同一套视觉语言。
+2. 信息层级清晰:标题、指标、风险、操作区一眼分层。
+3. 专业可信:克制、高密度但不混乱、细节一致。
+
+### 1.2 In Scope(第一阶段)
+
+1. 全局壳层:侧边栏、顶栏、面包屑、页面头、卡片容器。
+2. 设计基础:色彩/字体/间距/圆角/阴影/状态色 token 统一。
+3. 高曝光 10 页:
+ - `/project/overview`
+ - `/project/milestones`
+ - `/subjects`
+ - `/risk-issues/sae`
+ - `/risk-issues/pd`
+ - `/risk-issues/monitoring-visits`
+ - `/fees/contracts`
+ - `/fees/special`
+ - `/file-versions`
+ - `/admin/projects/:projectId`
+4. 轻交互:筛选区、表格工具栏、主次按钮层级、空态/加载态统一。
+
+### 1.3 Out of Scope(第一阶段不做)
+
+1. 业务流程重构和权限逻辑改动。
+2. 新增复杂图表引擎或大规模前端架构迁移。
+3. 全量页面一次性翻新。
+
+## 2. 视觉方向与设计系统基线
+
+### 2.1 视觉方向
+
+**Executive Clinical Minimalism(管理层临床极简)**
+
+关键词:克制、秩序、可信、少而精。
+避免:花哨渐变、互联网运营感、过度装饰。
+
+### 2.2 品牌识别锚点
+
+1. 深海军蓝 + 冷灰主色体系,传达管理层报告感。
+2. 高对比标题 + 低饱和数据面,强化信息主次。
+3. 统一壳层卡片语言,确保跨页面一致识别。
+
+### 2.3 最小设计系统(MDS)
+
+1. Tokens
+ - Color:`brand / text / bg / border / status`
+ - Type:`Display(标题) + UI(正文)` 双字体体系
+ - Space:8pt 网格(`4/8/12/16/24/32`)
+ - Radius/Shadow:统一 3 档,页面禁止自定义分叉
+2. 组件皮肤(Element Plus 主题化)
+ - Button(主/次/危险/幽灵)
+ - Input/Select/DatePicker
+ - Table(表头、行高、hover、密度)
+ - Card/Section Header/Tag
+3. 交互规则
+ - 页面主按钮固定在右上主操作区
+ - 筛选条统一(左筛选、右动作)
+ - 表格区统一“工具栏-表格-分页”三段结构
+
+### 2.4 第一阶段质量门槛
+
+1. 10 页视觉一致性 >= 90%。
+2. 不出现第三套颜色、第三套圆角、第三套按钮风格。
+3. 1366 桌面与 iPad 宽度保持结构稳定。
+
+## 3. 优先级与里程碑
+
+### 3.1 优先级
+
+#### P0(必须完成)
+1. 全局壳层统一:`Layout.vue + main.css + unified-page.css`。
+2. 10 个核心页面统一改造(见 In Scope)。
+3. 统一状态体系:加载态、空态、错误态、禁用态、风险高亮态。
+
+#### P1(有余量时)
+1. `/workbench` 视觉升级。
+2. 表单页统一(新建/编辑页头、分组、主次按钮)。
+3. 克制型关键动效(入场/hover)。
+
+#### P2(后续阶段)
+1. 全模块扩展(知识库、启动授权细分页)。
+2. 深度交互优化(跨页筛选记忆、快捷操作)。
+3. 主题扩展(暗色/品牌变体,按需)。
+
+### 3.2 两周里程碑
+
+#### M1(第 1-3 天):视觉基线锁定
+1. 冻结 tokens(颜色/字体/间距/阴影/圆角)。
+2. 产出 3 类样板页(概览、列表、详情)。
+3. 评审后锁定不可随意改动项。
+
+#### M2(第 4-7 天):高曝光页面落地(第一批)
+1. 完成 P0 页面中的 6 页。
+2. 统一页面头、筛选条、表格工具栏、分页区。
+3. 首轮一致性巡检与收敛。
+
+#### M3(第 8-10 天):P0 收敛(第二批)
+1. 完成剩余 4 页。
+2. 统一空态/加载态/错误态。
+3. 窄屏最小可用适配。
+
+#### M4(第 11-14 天):验收与打磨
+1. 演示路径串联:概览 -> 风险 -> 进度 -> 费用。
+2. 视觉一致性检查(目标 >= 90%)。
+3. 性能与可访问性快速校验并修复阻断项。
+
+## 4. 执行架构与风险控制
+
+### 4.1 双轨并行执行
+
+1. 轨道 A(20%):Design System 底座
+ - 固化 token、组件皮肤、壳层规范。
+2. 轨道 B(80%):核心页面改造
+ - 严格按 P0 页面推进,仅做视觉 + 轻交互。
+
+### 4.2 工作机制
+
+1. 每日节奏:样板页 -> 批量套用 -> 巡检收敛。
+2. 每 2 天一次统一性评审(跨页面对齐)。
+3. 决策优先级:统一性 > 局部美化。
+
+### 4.3 主要风险与控制
+
+1. 风格分叉
+ - 控制:样式入口收敛到全局 token 与统一壳层 class。
+2. 时间被细节吞没
+ - 控制:先结构统一,后细节打磨;P1 不挤占 P0。
+3. 观感升级但管理层不买账
+ - 控制:围绕高管路径做演示脚本。
+4. 样式改动引入回归
+ - 控制:每页改造后执行关键路径 smoke(筛选/跳转/分页/导出)。
+
+## 5. 第一阶段验收清单
+
+1. 统一性:10 页通过同一套视觉检查项。
+2. 观感:15 分钟演示无明显风格跳变与信息噪声。
+3. 可用性:主操作、筛选、列表关系明确。
+4. 稳定性:桌面与窄屏无结构破坏。
+5. 风险控制:无新增业务逻辑缺陷。
+
+## 6. 决策结论
+
+1. 采用“Design System 20% + 页面改造 80%”双轨并行。
+2. 第一阶段严格完成 P0;P1 仅在余量下执行。
+3. 下一步进入实施计划拆解(writing-plans)。
diff --git a/docs/plans/2026-03-04-ctms-enterprise-ui-implementation.md b/docs/plans/2026-03-04-ctms-enterprise-ui-implementation.md
new file mode 100644
index 00000000..f811be6c
--- /dev/null
+++ b/docs/plans/2026-03-04-ctms-enterprise-ui-implementation.md
@@ -0,0 +1,465 @@
+# CTMS Enterprise UI Refresh Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Deliver a management-grade, visually unified CTMS UI across 10 core pages in 1-2 weeks, without changing business logic.
+
+**Architecture:** Use a dual-track rollout: (A) minimal design-system hardening (tokens + shell + shared states), then (B) apply the same UI contract across prioritized pages. All page work must consume centralized CSS variables and shared shell patterns; page-local visual forks are disallowed.
+
+**Tech Stack:** Vue 3, TypeScript, Vite, Element Plus, CSS variables
+
+---
+
+Skills to apply during execution: `@test-driven-development`, `@frontend-design`, `@verification-before-completion`, `@requesting-code-review`.
+
+### Task 1: Add UI Contract Guardrails (Automated Checks)
+
+**Files:**
+- Create: `frontend/scripts/verify-ui-contract.mjs`
+- Modify: `frontend/package.json`
+- Test: `frontend/scripts/verify-ui-contract.mjs`
+
+**Step 1: Write the failing test**
+
+Create `frontend/scripts/verify-ui-contract.mjs`:
+
+```js
+import { readFileSync } from "node:fs";
+
+const main = readFileSync("src/styles/main.css", "utf8");
+const unified = readFileSync("src/styles/unified-page.css", "utf8");
+
+const requiredMainTokens = [
+ "--ctms-primary",
+ "--ctms-text-main",
+ "--ctms-bg-base",
+ "--ctms-radius",
+ "--ctms-shadow"
+];
+
+const requiredUnifiedTokens = [
+ "--unified-shell-bg",
+ "--unified-shell-radius",
+ ".unified-action-bar",
+ ".unified-section"
+];
+
+const missing = [
+ ...requiredMainTokens.filter((t) => !main.includes(t)),
+ ...requiredUnifiedTokens.filter((t) => !unified.includes(t))
+];
+
+if (missing.length) {
+ console.error("UI contract check failed. Missing:", missing.join(", "));
+ process.exit(1);
+}
+
+console.log("UI contract check passed");
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `cd frontend && node scripts/verify-ui-contract.mjs`
+Expected: FAIL (`ENOENT` for script file before creation, then missing-token failures until Task 2/3 done)
+
+**Step 3: Write minimal implementation**
+
+Update `frontend/package.json` scripts:
+
+```json
+{
+ "scripts": {
+ "ui:contract": "node scripts/verify-ui-contract.mjs"
+ }
+}
+```
+
+**Step 4: Run test to verify it passes**
+
+Run: `cd frontend && npm run ui:contract`
+Expected: PASS with `UI contract check passed` after Tasks 2/3 complete.
+
+**Step 5: Commit**
+
+```bash
+git add frontend/scripts/verify-ui-contract.mjs frontend/package.json
+git commit -m "chore(ui): add enterprise ui contract checks"
+```
+
+### Task 2: Harden Global Design Tokens (Single Source of Truth)
+
+**Files:**
+- Modify: `frontend/src/styles/main.css`
+- Test: `frontend/scripts/verify-ui-contract.mjs`
+
+**Step 1: Write the failing test**
+
+Add new required tokens into `requiredMainTokens` in `verify-ui-contract.mjs`:
+
+```js
+"--ctms-brand-900",
+"--ctms-brand-700",
+"--ctms-neutral-100",
+"--ctms-neutral-300",
+"--ctms-focus-ring"
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `cd frontend && npm run ui:contract`
+Expected: FAIL listing the newly required tokens as missing.
+
+**Step 3: Write minimal implementation**
+
+In `frontend/src/styles/main.css`, define enterprise tokens in `:root`:
+
+```css
+:root {
+ --ctms-brand-900: #13243a;
+ --ctms-brand-700: #24496f;
+ --ctms-neutral-100: #f5f7fa;
+ --ctms-neutral-300: #d5dce5;
+ --ctms-focus-ring: 0 0 0 3px rgba(36, 73, 111, 0.2);
+}
+```
+
+**Step 4: Run test to verify it passes**
+
+Run: `cd frontend && npm run ui:contract`
+Expected: PASS for these tokens.
+
+**Step 5: Commit**
+
+```bash
+git add frontend/src/styles/main.css frontend/scripts/verify-ui-contract.mjs
+git commit -m "feat(ui): add enterprise token baseline"
+```
+
+### Task 3: Standardize Shell Layout & Page Container Contract
+
+**Files:**
+- Modify: `frontend/src/styles/unified-page.css`
+- Modify: `frontend/src/components/Layout.vue`
+- Test: `frontend/scripts/verify-ui-contract.mjs`
+
+**Step 1: Write the failing test**
+
+Extend `requiredUnifiedTokens` with:
+
+```js
+".ctms-page-shell",
+".ctms-page-header-row",
+".ctms-page-content-grid"
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `cd frontend && npm run ui:contract`
+Expected: FAIL showing missing shell classes.
+
+**Step 3: Write minimal implementation**
+
+Add shell classes in `unified-page.css`:
+
+```css
+.ctms-page-shell { padding: 16px 20px 20px; }
+.ctms-page-header-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
+.ctms-page-content-grid { display: grid; gap: 12px; }
+```
+
+In `Layout.vue`, ensure routed views are wrapped by a stable shell root class.
+
+**Step 4: Run test to verify it passes**
+
+Run: `cd frontend && npm run ui:contract && npm run type-check`
+Expected: PASS.
+
+**Step 5: Commit**
+
+```bash
+git add frontend/src/styles/unified-page.css frontend/src/components/Layout.vue frontend/scripts/verify-ui-contract.mjs
+git commit -m "feat(ui): enforce shared page shell contract"
+```
+
+### Task 4: Create Shared List-Page Visual Pattern
+
+**Files:**
+- Modify: `frontend/src/views/ia/ProjectMilestones.vue`
+- Modify: `frontend/src/views/ia/SubjectManagement.vue`
+- Modify: `frontend/src/views/ia/RiskIssueSae.vue`
+- Modify: `frontend/src/views/ia/RiskIssuePd.vue`
+- Modify: `frontend/src/views/ia/RiskIssueMonitoringVisits.vue`
+- Test: `frontend/src/views/ia/*.vue`
+
+**Step 1: Write the failing test**
+
+In `verify-ui-contract.mjs`, add per-page checks that each target file includes:
+
+```js
+"ctms-page-shell",
+"unified-action-bar",
+"ctms-table-card"
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `cd frontend && npm run ui:contract`
+Expected: FAIL on one or more target views.
+
+**Step 3: Write minimal implementation**
+
+Refactor each page template to the same structure:
+
+```vue
+
+
+
+
+```
+
+**Step 4: Run test to verify it passes**
+
+Run: `cd frontend && npm run ui:contract && npm run type-check`
+Expected: PASS.
+
+**Step 5: Commit**
+
+```bash
+git add frontend/src/views/ia/ProjectMilestones.vue frontend/src/views/ia/SubjectManagement.vue frontend/src/views/ia/RiskIssueSae.vue frontend/src/views/ia/RiskIssuePd.vue frontend/src/views/ia/RiskIssueMonitoringVisits.vue frontend/scripts/verify-ui-contract.mjs
+git commit -m "feat(ui): unify list-page enterprise visual pattern"
+```
+
+### Task 5: Upgrade Executive Dashboard Surfaces
+
+**Files:**
+- Modify: `frontend/src/views/ia/ProjectOverview.vue`
+- Modify: `frontend/src/components/KpiCard.vue`
+- Modify: `frontend/src/styles/unified-page.css`
+- Test: `frontend/src/views/ia/ProjectOverview.vue`
+
+**Step 1: Write the failing test**
+
+Add checks requiring overview page contains:
+
+```js
+"ctms-page-shell",
+"kpi",
+"unified-section"
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `cd frontend && npm run ui:contract`
+Expected: FAIL until overview/kpi structure is aligned.
+
+**Step 3: Write minimal implementation**
+
+- Ensure KPI cards use unified spacing, title scale, value prominence.
+- Add deterministic class contract to KPI root:
+
+```vue
+...
+```
+
+- Add style rules in `unified-page.css` for KPI rhythm and hierarchy.
+
+**Step 4: Run test to verify it passes**
+
+Run: `cd frontend && npm run ui:contract && npm run type-check`
+Expected: PASS.
+
+**Step 5: Commit**
+
+```bash
+git add frontend/src/views/ia/ProjectOverview.vue frontend/src/components/KpiCard.vue frontend/src/styles/unified-page.css frontend/scripts/verify-ui-contract.mjs
+git commit -m "feat(ui): elevate project overview for executive readout"
+```
+
+### Task 6: Standardize Finance & File Management Pages
+
+**Files:**
+- Modify: `frontend/src/views/fees/ContractFees.vue`
+- Modify: `frontend/src/views/fees/SpecialExpenses.vue`
+- Modify: `frontend/src/views/ia/FileVersionManagement.vue`
+- Test: `frontend/src/views/fees/*.vue`, `frontend/src/views/ia/FileVersionManagement.vue`
+
+**Step 1: Write the failing test**
+
+Require common classes in target pages:
+
+```js
+"ctms-page-shell",
+"unified-action-bar",
+"unified-shell"
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `cd frontend && npm run ui:contract`
+Expected: FAIL for non-conforming pages.
+
+**Step 3: Write minimal implementation**
+
+Refactor templates to shared contract and align action hierarchy:
+- Primary action right-aligned.
+- Secondary actions grouped.
+- Filter and table blocks separated by unified sections.
+
+**Step 4: Run test to verify it passes**
+
+Run: `cd frontend && npm run ui:contract && npm run type-check`
+Expected: PASS.
+
+**Step 5: Commit**
+
+```bash
+git add frontend/src/views/fees/ContractFees.vue frontend/src/views/fees/SpecialExpenses.vue frontend/src/views/ia/FileVersionManagement.vue frontend/scripts/verify-ui-contract.mjs
+git commit -m "feat(ui): unify finance and file management surfaces"
+```
+
+### Task 7: Standardize Admin High-Visibility Project Page
+
+**Files:**
+- Modify: `frontend/src/views/admin/ProjectDetail.vue`
+- Modify: `frontend/src/styles/unified-page.css`
+- Test: `frontend/src/views/admin/ProjectDetail.vue`
+
+**Step 1: Write the failing test**
+
+Add contract checks for `ProjectDetail.vue` requiring:
+
+```js
+"ctms-page-shell",
+"unified-action-bar",
+"unified-section"
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `cd frontend && npm run ui:contract`
+Expected: FAIL until shell contract is applied.
+
+**Step 3: Write minimal implementation**
+
+- Normalize section titles, spacing, card treatment.
+- Remove page-specific visual overrides that conflict with token system.
+- Keep business fields and logic unchanged.
+
+**Step 4: Run test to verify it passes**
+
+Run: `cd frontend && npm run ui:contract && npm run type-check`
+Expected: PASS.
+
+**Step 5: Commit**
+
+```bash
+git add frontend/src/views/admin/ProjectDetail.vue frontend/src/styles/unified-page.css frontend/scripts/verify-ui-contract.mjs
+git commit -m "feat(ui): align admin project detail with enterprise system"
+```
+
+### Task 8: Unify Loading / Empty / Error States
+
+**Files:**
+- Modify: `frontend/src/components/StateLoading.vue`
+- Modify: `frontend/src/components/StateEmpty.vue`
+- Modify: `frontend/src/components/StateError.vue`
+- Modify: `frontend/src/styles/main.css`
+- Test: `frontend/src/components/State*.vue`
+
+**Step 1: Write the failing test**
+
+Add checks requiring all three state components include:
+
+```js
+"ctms-state",
+"ctms-state-title",
+"ctms-state-desc"
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `cd frontend && npm run ui:contract`
+Expected: FAIL for missing class contract.
+
+**Step 3: Write minimal implementation**
+
+Refactor each component to common structure:
+
+```vue
+
+```
+
+Add shared state styles in `main.css`.
+
+**Step 4: Run test to verify it passes**
+
+Run: `cd frontend && npm run ui:contract && npm run type-check`
+Expected: PASS.
+
+**Step 5: Commit**
+
+```bash
+git add frontend/src/components/StateLoading.vue frontend/src/components/StateEmpty.vue frontend/src/components/StateError.vue frontend/src/styles/main.css frontend/scripts/verify-ui-contract.mjs
+git commit -m "feat(ui): unify loading empty error states"
+```
+
+### Task 9: Verification, Demo Path, and Release Gate
+
+**Files:**
+- Create: `docs/ui/enterprise-ui-acceptance-checklist.md`
+- Modify: `docs/release-checklist.md`
+- Test: manual smoke + build checks
+
+**Step 1: Write the failing test**
+
+Create acceptance checklist with explicit pass/fail items. Mark all as pending initially.
+
+```md
+- [ ] Overview and milestones visual consistency
+- [ ] Risk pages visual consistency
+- [ ] Finance pages visual consistency
+- [ ] File/admin detail visual consistency
+- [ ] Desktop and iPad width checks
+- [ ] Management demo path continuity
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `cd frontend && npm run type-check && npm run build`
+Expected: PASS technically, but acceptance checklist remains FAIL/PENDING.
+
+**Step 3: Write minimal implementation**
+
+- Execute manual smoke on target routes.
+- Update checklist to PASS with reviewer/date notes.
+- Add release gating notes to `docs/release-checklist.md`.
+
+**Step 4: Run test to verify it passes**
+
+Run: `cd frontend && npm run ui:contract && npm run type-check && npm run build`
+Expected: PASS for all commands and checklist completed.
+
+**Step 5: Commit**
+
+```bash
+git add docs/ui/enterprise-ui-acceptance-checklist.md docs/release-checklist.md
+git commit -m "docs(ui): add enterprise refresh acceptance and release gates"
+```
+
+## Milestone Mapping
+
+1. **M1 (Day 1-3):** Tasks 1-3 + sample page alignment.
+2. **M2 (Day 4-7):** Tasks 4-5 (first 6 core pages).
+3. **M3 (Day 8-10):** Tasks 6-8 (remaining 4 pages + states).
+4. **M4 (Day 11-14):** Task 9 + demo rehearsal + release gate.
+
+## Definition of Done
+
+1. `npm run ui:contract` passes.
+2. `npm run type-check` passes.
+3. `npm run build` passes.
+4. Acceptance checklist fully marked PASS with reviewer/date.
+5. No business logic behavior regressions in smoke path.
diff --git a/docs/plans/2026-03-05-rd-monitor-design.md b/docs/plans/2026-03-05-rd-monitor-design.md
new file mode 100644
index 00000000..6df62d8c
--- /dev/null
+++ b/docs/plans/2026-03-05-rd-monitor-design.md
@@ -0,0 +1,97 @@
+# 研发项目进展监测系统(rd-monitor)设计文档
+
+## 1. 目标与范围
+- 基于现有 CTMS 的界面/组件经验,建设一个完全独立的研发项目进展监测系统。
+- 新系统独立前后端、独立数据库、独立鉴权与权限体系。
+- 首期仅实现 PDF《项目进度概览 v2》中的核心功能:概览展示、进度录入、历史变更、导出、项目隐藏规则。
+
+## 2. 系统边界
+- 独立仓库:`rd-monitor`
+- 技术栈:Vue3 + Element Plus + FastAPI + SQLAlchemy + Alembic + PostgreSQL
+- 与 CTMS 关系:仅复用可迁移的组件/样式实现,不依赖 CTMS API 与 CTMS 数据表。
+
+## 3. 数据模型
+### 3.1 users
+- `id`
+- `username`
+- `email`
+- `password_hash`
+- `role` (`ADMIN`/`PM`/`VIEWER`)
+- `is_active`
+- `created_at`
+
+### 3.2 projects
+- `id`
+- `project_code`(如 HP-001)
+- `project_name`
+- `regulatory_path`
+- `status`(`ACTIVE`/`APPROVED`/`TERMINATED`)
+- `created_at`
+
+### 3.3 stage_templates
+- `id`
+- `stage_key`
+- `stage_name`
+- `order_no`
+- `regulatory_paths`(JSON 数组;空表示通用)
+- `is_active`
+
+### 3.4 project_stages
+- `id`
+- `project_id`
+- `stage_template_id`
+- `order_no`
+- `planned_start_date`
+- `planned_end_date`
+- `actual_finish_date`
+- `actual_finish_flag`(`DONE`/`NOT_APPLICABLE`/`PENDING`)
+- `adjust_reason`
+- `remark`
+- `updated_at`
+
+### 3.5 project_stage_change_logs
+- `id`
+- `project_stage_id`
+- `changed_by`
+- `before_json`
+- `after_json`
+- `comment`
+- `created_at`
+
+## 4. 状态规则(按 PDF)
+- `已完成`:`actual_finish_flag = DONE` 且 `actual_finish_date` 有值。
+- `未进行`:`actual_finish_flag = NOT_APPLICABLE`(“此阶段不开展”)。
+- `正进行`:当前阶段未完成/未进行,且上一阶段状态为 `已完成`。
+- `未开始`:其余情况。
+- 概览显示日期:使用 `planned_end_date`。
+- 项目隐藏:`APPROVED` 或 `TERMINATED` 不在概览列表中显示。
+
+## 5. 页面与交互
+- 登录页(JWT)
+- 概览页:按项目展示阶段节点、计划完成日期、状态图例
+- 录入页:项目阶段表格编辑,支持“此阶段不开展”
+- 历史页:阶段变更历史时间线/表格
+- 导出:授权用户导出 CSV
+
+## 6. 权限模型
+- `ADMIN`:用户/项目/模板管理 + 全部导出
+- `PM`:项目进度录入、历史查看、导出
+- `VIEWER`:只读(概览、历史)
+
+## 7. API(首期)
+- `POST /api/v1/auth/login`
+- `GET /api/v1/auth/me`
+- `GET /api/v1/projects`
+- `POST /api/v1/projects`(ADMIN)
+- `GET /api/v1/stage-templates`
+- `POST /api/v1/stage-templates`(ADMIN)
+- `GET /api/v1/projects/{id}/stages`
+- `PATCH /api/v1/projects/{id}/stages/{stageId}`(PM/ADMIN)
+- `GET /api/v1/projects/{id}/stages/history`
+- `GET /api/v1/projects/stages/export?format=csv`(PM/ADMIN)
+
+## 8. 验收标准
+- 状态计算规则与 PDF 一致
+- 录入保存会生成历史记录
+- 导出字段与录入字段一致
+- `APPROVED/TERMINATED` 项目不在概览展示
diff --git a/docs/plans/2026-03-05-rd-monitor-implementation.md b/docs/plans/2026-03-05-rd-monitor-implementation.md
new file mode 100644
index 00000000..3f4c1189
--- /dev/null
+++ b/docs/plans/2026-03-05-rd-monitor-implementation.md
@@ -0,0 +1,137 @@
+# rd-monitor Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** 搭建独立仓库 `rd-monitor`,实现研发项目进展监测 MVP(独立鉴权、项目阶段录入、状态计算、历史留痕、CSV 导出、前端可用界面)。
+
+**Architecture:** 后端采用 FastAPI + SQLAlchemy + PostgreSQL,提供鉴权、项目阶段、历史、导出 API;前端采用 Vue3 + Element Plus,提供登录、概览、录入、历史页面。通过 Docker Compose 启动前后端与独立 PostgreSQL。
+
+**Tech Stack:** Vue3, Vite, Element Plus, FastAPI, SQLAlchemy, Alembic, PostgreSQL, JWT, Pytest
+
+---
+
+### Task 1: 初始化仓库结构与运行骨架
+
+**Files:**
+- Create: `/Users/zcc/MyCTMS/rd-monitor/README.md`
+- Create: `/Users/zcc/MyCTMS/rd-monitor/docker-compose.yml`
+- Create: `/Users/zcc/MyCTMS/rd-monitor/backend/requirements.txt`
+- Create: `/Users/zcc/MyCTMS/rd-monitor/backend/.env.example`
+- Create: `/Users/zcc/MyCTMS/rd-monitor/frontend/package.json`
+- Create: `/Users/zcc/MyCTMS/rd-monitor/frontend/vite.config.ts`
+
+**Step 1: Write the failing test**
+- 编写后端 smoke 测试,断言 `/healthz` 返回 200(此时接口不存在,应失败)。
+
+**Step 2: Run test to verify it fails**
+- Run: `cd /Users/zcc/MyCTMS/rd-monitor/backend && pytest -q`
+- Expected: FAIL(缺少 app 或路由)。
+
+**Step 3: Write minimal implementation**
+- 建立 FastAPI app 与 `/healthz` 路由,补全基础依赖与 compose。
+
+**Step 4: Run test to verify it passes**
+- Run: `cd /Users/zcc/MyCTMS/rd-monitor/backend && pytest -q`
+- Expected: PASS。
+
+**Step 5: Commit**
+- `git add ... && git commit -m "chore: bootstrap rd-monitor skeleton"`
+
+### Task 2: 实现后端核心领域模型与状态计算
+
+**Files:**
+- Create: `/Users/zcc/MyCTMS/rd-monitor/backend/app/models/*.py`
+- Create: `/Users/zcc/MyCTMS/rd-monitor/backend/app/services/progress.py`
+- Test: `/Users/zcc/MyCTMS/rd-monitor/backend/tests/test_progress_status.py`
+
+**Step 1: Write the failing test**
+- 用测试覆盖阶段状态计算(已完成/未进行/正进行/未开始)与项目过滤规则。
+
+**Step 2: Run test to verify it fails**
+- Run: `pytest tests/test_progress_status.py -q`
+- Expected: FAIL。
+
+**Step 3: Write minimal implementation**
+- 实现状态计算函数与项目过滤函数。
+
+**Step 4: Run test to verify it passes**
+- Run: `pytest tests/test_progress_status.py -q`
+- Expected: PASS。
+
+**Step 5: Commit**
+- `git commit -m "feat: add progress status domain rules"`
+
+### Task 3: 实现鉴权、项目阶段 API、历史记录与导出
+
+**Files:**
+- Create/Modify: `/Users/zcc/MyCTMS/rd-monitor/backend/app/api/*.py`
+- Create/Modify: `/Users/zcc/MyCTMS/rd-monitor/backend/app/schemas/*.py`
+- Create/Modify: `/Users/zcc/MyCTMS/rd-monitor/backend/app/core/security.py`
+- Test: `/Users/zcc/MyCTMS/rd-monitor/backend/tests/test_stage_api.py`
+
+**Step 1: Write the failing test**
+- 测试登录、查询项目阶段、更新阶段后历史新增、导出 CSV、权限拦截。
+
+**Step 2: Run test to verify it fails**
+- Run: `pytest tests/test_stage_api.py -q`
+- Expected: FAIL。
+
+**Step 3: Write minimal implementation**
+- 完成相关 API 与依赖。
+
+**Step 4: Run test to verify it passes**
+- Run: `pytest tests/test_stage_api.py -q`
+- Expected: PASS。
+
+**Step 5: Commit**
+- `git commit -m "feat: implement progress APIs with auth history and export"`
+
+### Task 4: 实现前端登录、概览、录入、历史页面
+
+**Files:**
+- Create: `/Users/zcc/MyCTMS/rd-monitor/frontend/src/views/*.vue`
+- Create: `/Users/zcc/MyCTMS/rd-monitor/frontend/src/router/index.ts`
+- Create: `/Users/zcc/MyCTMS/rd-monitor/frontend/src/api/*.ts`
+- Create: `/Users/zcc/MyCTMS/rd-monitor/frontend/src/components/*.vue`
+
+**Step 1: Write the failing test**
+- 首期用类型检查/构建作为回归保护,先运行应失败(页面未实现)。
+
+**Step 2: Run test to verify it fails**
+- Run: `cd /Users/zcc/MyCTMS/rd-monitor/frontend && npm run build`
+- Expected: FAIL。
+
+**Step 3: Write minimal implementation**
+- 实现可登录、可浏览项目阶段、可编辑阶段、可看历史、可导出。
+
+**Step 4: Run test to verify it passes**
+- Run: `npm run build`
+- Expected: PASS。
+
+**Step 5: Commit**
+- `git commit -m "feat: add frontend pages for monitoring workflow"`
+
+### Task 5: 联调与最终验证
+
+**Files:**
+- Modify: `/Users/zcc/MyCTMS/rd-monitor/README.md`
+
+**Step 1: Write the failing test**
+- 先运行端到端 smoke(未配置好前应失败)。
+
+**Step 2: Run test to verify it fails**
+- Run: `docker compose up --build -d && curl -f http://localhost:8010/healthz`
+- Expected: 初次 FAIL 或服务未就绪。
+
+**Step 3: Write minimal implementation**
+- 修正配置/环境变量/文档。
+
+**Step 4: Run test to verify it passes**
+- Run: `docker compose up --build -d`
+- Run: `curl -f http://localhost:8010/healthz`
+- Run: `cd backend && pytest -q`
+- Run: `cd frontend && npm run build`
+- Expected: 全部 PASS。
+
+**Step 5: Commit**
+- `git commit -m "docs: finalize rd-monitor setup and verification"`
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index bd5ea96e..876fc3f6 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -18,11 +18,27 @@
"devDependencies": {
"@types/node": "^20.10.5",
"@vitejs/plugin-vue": "^6.0.3",
+ "jsdom": "^26.1.0",
"typescript": "^5.3.3",
"vite": "^7.3.1",
+ "vitest": "^3.2.4",
"vue-tsc": "^3.2.2"
}
},
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.3",
+ "@csstools/css-color-parser": "^3.0.9",
+ "@csstools/css-parser-algorithms": "^3.0.4",
+ "@csstools/css-tokenizer": "^3.0.3",
+ "lru-cache": "^10.4.3"
+ }
+ },
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"license": "MIT",
@@ -61,6 +77,121 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@ctrl/tinycolor": {
"version": "3.6.1",
"license": "MIT",
@@ -848,6 +979,24 @@
"win32"
]
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"dev": true,
@@ -860,7 +1009,6 @@
"node_modules/@types/lodash-es": {
"version": "4.17.12",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/lodash": "*"
}
@@ -869,7 +1017,6 @@
"version": "20.19.27",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -895,6 +1042,131 @@
"vue": "^3.2.25"
}
},
+ "node_modules/@vitest/expect": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
+ "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
+ "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "3.2.4",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/mocker/node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
+ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.4",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
+ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
+ "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
+ "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/@volar/language-core": {
"version": "2.4.27",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.27.tgz",
@@ -1056,6 +1328,16 @@
"url": "https://github.com/sponsors/antfu"
}
},
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/alien-signals": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz",
@@ -1063,6 +1345,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/async-validator": {
"version": "4.2.5",
"license": "MIT"
@@ -1080,6 +1372,16 @@
"proxy-from-env": "^1.1.0"
}
},
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"license": "MIT",
@@ -1091,6 +1393,33 @@
"node": ">= 0.4"
}
},
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
"node_modules/combined-stream": {
"version": "1.0.8",
"license": "MIT",
@@ -1101,10 +1430,38 @@
"node": ">= 0.8"
}
},
+ "node_modules/cssstyle": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^3.2.0",
+ "rrweb-cssom": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"license": "MIT"
},
+ "node_modules/data-urls": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
@@ -1119,6 +1476,41 @@
"version": "1.11.19",
"license": "MIT"
},
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/delayed-stream": {
"version": "1.0.0",
"license": "MIT",
@@ -1185,6 +1577,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/es-object-atoms": {
"version": "1.1.1",
"license": "MIT",
@@ -1254,6 +1653,16 @@
"version": "2.0.2",
"license": "MIT"
},
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1402,15 +1811,121 @@
"node": ">= 0.4"
}
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsdom": {
+ "version": "26.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
+ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssstyle": "^4.2.1",
+ "data-urls": "^5.0.0",
+ "decimal.js": "^10.5.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.16",
+ "parse5": "^7.2.1",
+ "rrweb-cssom": "^0.8.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^5.1.1",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.1.1",
+ "ws": "^8.18.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
"node_modules/lodash": {
"version": "4.17.21",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.21",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/lodash-unified": {
"version": "1.0.3",
@@ -1421,6 +1936,20 @@
"lodash-es": "*"
}
},
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"license": "MIT",
@@ -1456,6 +1985,13 @@
"node": ">= 0.6"
}
},
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
@@ -1483,6 +2019,39 @@
"version": "1.2.0",
"license": "BSD-3-Clause"
},
+ "node_modules/nwsapi": {
+ "version": "2.2.23",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
+ "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5/node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -1490,6 +2059,23 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"license": "ISC"
@@ -1500,7 +2086,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -1558,6 +2143,16 @@
"version": "1.1.0",
"license": "MIT"
},
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/rollup": {
"version": "4.53.5",
"dev": true,
@@ -1612,6 +2207,40 @@
"darwin"
]
},
+ "node_modules/rrweb-cssom": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"license": "BSD-3-Clause",
@@ -1619,6 +2248,54 @@
"node": ">=0.10.0"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-literal": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -1636,11 +2313,86 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^6.1.32"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/typescript": {
"version": "5.9.3",
"devOptional": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -1660,7 +2412,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -1730,6 +2481,102 @@
}
}
},
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
+ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.4",
+ "@vitest/mocker": "3.2.4",
+ "@vitest/pretty-format": "^3.2.4",
+ "@vitest/runner": "3.2.4",
+ "@vitest/snapshot": "3.2.4",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.4",
+ "@vitest/ui": "3.2.4",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
@@ -1740,7 +2587,6 @@
"node_modules/vue": {
"version": "3.5.25",
"license": "MIT",
- "peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.25",
"@vue/compiler-sfc": "3.5.25",
@@ -1810,6 +2656,123 @@
"peerDependencies": {
"typescript": ">=5.0.0"
}
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
}
}
}
diff --git a/frontend/package.json b/frontend/package.json
index 9621bd0c..a6396332 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -7,6 +7,7 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
+ "test:unit": "vitest run --environment jsdom",
"type-check": "vue-tsc --noEmit",
"ui:contract": "node scripts/verify-ui-contract.mjs"
},
@@ -21,8 +22,10 @@
"devDependencies": {
"@types/node": "^20.10.5",
"@vitejs/plugin-vue": "^6.0.3",
+ "jsdom": "^26.1.0",
"typescript": "^5.3.3",
"vite": "^7.3.1",
+ "vitest": "^3.2.4",
"vue-tsc": "^3.2.2"
}
}
diff --git a/frontend/src/components/LockScreenModal.vue b/frontend/src/components/LockScreenModal.vue
index ddc0682b..4fdba4f1 100644
--- a/frontend/src/components/LockScreenModal.vue
+++ b/frontend/src/components/LockScreenModal.vue
@@ -51,7 +51,7 @@ const pad2 = (value: number) => String(value).padStart(2, "0");
const autoLogoutCountdown = computed(() => {
const fallbackDeadlineAt =
- Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt) + AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
+ Math.max(session.lastUserActiveAt, session.lastNetworkActiveAt) + AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
const deadlineAt = session.lockAutoLogoutDeadlineAt || fallbackDeadlineAt;
const remainMs = Math.max(0, deadlineAt - nowTs.value);
const remainSec = Math.floor(remainMs / 1000);
diff --git a/frontend/src/session/sessionManager.test.ts b/frontend/src/session/sessionManager.test.ts
new file mode 100644
index 00000000..794fd447
--- /dev/null
+++ b/frontend/src/session/sessionManager.test.ts
@@ -0,0 +1,86 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { createPinia, setActivePinia } from "pinia";
+
+vi.mock("../router", () => ({
+ default: {
+ replace: vi.fn(),
+ },
+}));
+
+vi.mock("../api/authClient", () => ({
+ extendToken: vi.fn(),
+ unlockSession: vi.fn(),
+}));
+
+describe("session manager idle recovery", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.useFakeTimers();
+ setActivePinia(createPinia());
+ const storage = (() => {
+ const data = new Map();
+ return {
+ getItem: (key: string) => data.get(key) ?? null,
+ setItem: (key: string, value: string) => {
+ data.set(key, String(value));
+ },
+ removeItem: (key: string) => {
+ data.delete(key);
+ },
+ clear: () => {
+ data.clear();
+ },
+ };
+ })();
+ Object.defineProperty(window, "localStorage", {
+ value: storage,
+ configurable: true,
+ });
+ Object.defineProperty(window, "sessionStorage", {
+ value: storage,
+ configurable: true,
+ });
+ Object.defineProperty(window, "BroadcastChannel", {
+ value: class {
+ onmessage: ((event: MessageEvent) => void) | null = null;
+ postMessage() {}
+ close() {}
+ },
+ configurable: true,
+ });
+ });
+
+ it("locks instead of refreshing activity after long inactivity when user activity resumes", async () => {
+ vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z"));
+ const { useSessionStore } = await import("../store/session");
+ const { IDLE_TIMEOUT_MINUTES, AUTO_LOGOUT_TIMEOUT_HOURS, markUserActive } = await import("./sessionManager");
+ const session = useSessionStore();
+ const oldTs = Date.now() - (IDLE_TIMEOUT_MINUTES * 60 * 1000 + 1_000);
+
+ session.recordUserActivity(oldTs);
+ session.recordNetworkActivity(oldTs);
+
+ markUserActive(Date.now());
+
+ expect(session.locked).toBe(true);
+ expect(session.lockReason).toBe("idle");
+ expect(session.lockAutoLogoutDeadlineAt).toBe(oldTs + AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000);
+ });
+
+ it("locks instead of refreshing activity after long inactivity when network activity resumes", async () => {
+ vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z"));
+ const { useSessionStore } = await import("../store/session");
+ const { IDLE_TIMEOUT_MINUTES, AUTO_LOGOUT_TIMEOUT_HOURS, markNetworkActive } = await import("./sessionManager");
+ const session = useSessionStore();
+ const oldTs = Date.now() - (IDLE_TIMEOUT_MINUTES * 60 * 1000 + 1_000);
+
+ session.recordUserActivity(oldTs);
+ session.recordNetworkActivity(oldTs);
+
+ markNetworkActive(Date.now());
+
+ expect(session.locked).toBe(true);
+ expect(session.lockReason).toBe("idle");
+ expect(session.lockAutoLogoutDeadlineAt).toBe(oldTs + AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000);
+ });
+});
diff --git a/frontend/src/session/sessionManager.ts b/frontend/src/session/sessionManager.ts
index 67d76963..0157e415 100644
--- a/frontend/src/session/sessionManager.ts
+++ b/frontend/src/session/sessionManager.ts
@@ -25,6 +25,26 @@ let extendPromise: Promise<{ token: string | null; authFailed: boolean }> | null
let initialized = false;
const channel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("ctms-auth") : null;
+const getLastActiveAt = (session: ReturnType) =>
+ Math.max(session.lastUserActiveAt, session.lastNetworkActiveAt);
+
+const reconcileSessionState = (now: number = Date.now()) => {
+ const session = useSessionStore();
+ if (session.locked) return false;
+ const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
+ const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
+ const lastActiveAt = getLastActiveAt(session);
+ if (now - lastActiveAt > autoLogoutMs) {
+ forceLogout(LOGOUT_REASON_TIMEOUT);
+ return false;
+ }
+ if (now - lastActiveAt > idleTimeoutMs) {
+ lockSession("idle");
+ return false;
+ }
+ return true;
+};
+
const broadcast = (message: BroadcastMessage) => {
if (channel) {
channel.postMessage(message);
@@ -81,9 +101,9 @@ const scheduleIdleCheck = () => {
}
const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
- const baseActiveAt = Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt);
- const idleRemain = baseActiveAt + idleTimeoutMs - now;
- const logoutRemain = baseActiveAt + autoLogoutMs - now;
+ const lastActiveAt = getLastActiveAt(session);
+ const idleRemain = lastActiveAt + idleTimeoutMs - now;
+ const logoutRemain = lastActiveAt + autoLogoutMs - now;
const nextCheck = Math.min(idleRemain, logoutRemain);
if (nextCheck <= 0) {
checkIdle();
@@ -103,19 +123,7 @@ const checkIdle = () => {
scheduleIdleCheck();
return;
}
- const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
- const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
- const now = Date.now();
- const idleElapsed = now - session.lastUserActiveAt > idleTimeoutMs && now - session.lastNetworkActiveAt > idleTimeoutMs;
- const autoLogoutElapsed =
- now - session.lastUserActiveAt > autoLogoutMs && now - session.lastNetworkActiveAt > autoLogoutMs;
- if (autoLogoutElapsed) {
- forceLogout(LOGOUT_REASON_TIMEOUT);
- return;
- }
- if (idleElapsed) {
- lockSession("idle");
- }
+ if (!reconcileSessionState(Date.now())) return;
scheduleIdleCheck();
};
@@ -131,6 +139,14 @@ export const initSessionManager = () => {
markUserActive(now);
};
events.forEach((event) => window.addEventListener(event, onUserActivity, { passive: true }));
+ window.addEventListener("focus", () => {
+ checkIdle();
+ });
+ document.addEventListener("visibilitychange", () => {
+ if (!document.hidden) {
+ checkIdle();
+ }
+ });
if (channel) {
channel.onmessage = (event) => handleBroadcast(event.data as BroadcastMessage);
}
@@ -150,6 +166,7 @@ export const initSessionManager = () => {
export const markUserActive = (at: number = Date.now()) => {
const session = useSessionStore();
if (session.locked) return;
+ if (!reconcileSessionState(at)) return;
session.recordUserActivity(at);
broadcast({ type: "ACTIVE", at });
scheduleIdleCheck();
@@ -158,6 +175,7 @@ export const markUserActive = (at: number = Date.now()) => {
export const markNetworkActive = (at: number = Date.now()) => {
const session = useSessionStore();
if (session.locked) return;
+ if (!reconcileSessionState(at)) return;
session.recordNetworkActivity(at);
broadcast({ type: "NETWORK", at });
scheduleIdleCheck();
@@ -169,7 +187,7 @@ export const lockSession = (reason: "idle" | "extend_failed" | "token_invalid")
if (session.locked) return;
const email = auth.user?.email || "";
const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
- const deadlineAt = Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt) + autoLogoutMs;
+ const deadlineAt = getLastActiveAt(session) + autoLogoutMs;
session.lock(reason, email, deadlineAt);
broadcast({ type: "LOCK", reason, email, deadlineAt });
scheduleIdleCheck();
@@ -264,7 +282,7 @@ export const startTokenKeepAlive = () => {
if (!expAt) return;
const remaining = expAt - Date.now();
const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
- const active = Date.now() - session.lastUserActiveAt < timeoutMs || Date.now() - session.lastNetworkActiveAt < timeoutMs;
+ const active = Date.now() - getLastActiveAt(session) < timeoutMs;
if (active && remaining < EXTEND_EARLY_SECONDS * 1000) {
void extendAccessToken("early");
}
diff --git a/frontend/src/store/auth.test.ts b/frontend/src/store/auth.test.ts
new file mode 100644
index 00000000..67d5cc1a
--- /dev/null
+++ b/frontend/src/store/auth.test.ts
@@ -0,0 +1,49 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { createPinia, setActivePinia } from "pinia";
+
+type StorageLike = {
+ getItem: (key: string) => string | null;
+ setItem: (key: string, value: string) => void;
+ removeItem: (key: string) => void;
+ clear: () => void;
+};
+
+const createStorage = (): StorageLike => {
+ const data = new Map();
+ return {
+ getItem: (key) => data.get(key) ?? null,
+ setItem: (key, value) => {
+ data.set(key, String(value));
+ },
+ removeItem: (key) => {
+ data.delete(key);
+ },
+ clear: () => {
+ data.clear();
+ },
+ };
+};
+
+describe("auth store logout", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ Object.defineProperty(window, "localStorage", {
+ value: createStorage(),
+ configurable: true,
+ });
+ });
+
+ it("clears session lock state when logging out", async () => {
+ const { useAuthStore } = await import("./auth");
+ const { useSessionStore } = await import("./session");
+ const session = useSessionStore();
+ const auth = useAuthStore();
+
+ session.lock("token_invalid", "demo@example.com", Date.now() + 60_000);
+ auth.logout();
+
+ expect(session.locked).toBe(false);
+ expect(session.lockReason).toBeNull();
+ expect(session.lockEmail).toBe("");
+ });
+});
diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts
index 5239f633..18a5979e 100644
--- a/frontend/src/store/auth.ts
+++ b/frontend/src/store/auth.ts
@@ -43,11 +43,13 @@ export const useAuthStore = defineStore("auth", () => {
const logout = () => {
const studyStore = useStudyStore();
+ const sessionStore = useSessionStore();
const userKey = user.value?.email || localStorage.getItem(LAST_LOGIN_EMAIL_KEY) || "";
studyStore.rememberCurrentStudyForUser(userKey);
token.value = null;
user.value = null;
forceLogin.value = false;
+ sessionStore.unlock();
clearToken();
studyStore.clearCurrentStudy();
};
diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue
index 669ffc86..7191cd5b 100644
--- a/frontend/src/views/Login.vue
+++ b/frontend/src/views/Login.vue
@@ -1,117 +1,101 @@
-