From 3652ea854b8cb3b60096618f5833ae61a796a56b Mon Sep 17 00:00:00 2001 From: Hsukqi Lee Date: Wed, 11 Nov 2026 06:05:55 +0800 Subject: [PATCH 1/4] refactor: unify workbench UI and released integration clients Use immutable public release artifacts and retain product-specific behavior. Independent review and real release installation recorded in the sharing handoff. --- .env.example | 7 + .env.production.example | 4 + .github/scripts/ci-node.sh | 5 + .github/workflows/cd.yml | 3 +- .github/workflows/tag-ci.yml | 15 +- Dockerfile | 3 - README.md | 4 +- contracts/fastresearch-today/v1/README.md | 21 + contracts/fastresearch-today/v1/cases.json | 45 + docker-compose.yml | 2 - docs/FASTCAS.md | 4 +- docs/api.md | 2 +- package-lock.json | 82 +- package.json | 5 +- scripts/fasttask-today-contract.mjs | 73 + scripts/fastui-browser.mjs | 115 + server/fasttask-client.mjs | 24 +- server/fasttask-today-client.mjs | 57 + server/fasttask-today-client.test.mjs | 66 + server/index.mjs | 5 +- src/App2.tsx | 32 +- src/FASTUI.md | 11 + src/PromoPage.tsx | 3 +- src/color-mode.ts | 13 + src/i18n.ts | 9 +- src/index.css | 3255 +++----------------- src/main.tsx | 2 + src/promo.css | 24 +- 28 files changed, 938 insertions(+), 2953 deletions(-) create mode 100644 .github/scripts/ci-node.sh create mode 100644 contracts/fastresearch-today/v1/README.md create mode 100644 contracts/fastresearch-today/v1/cases.json create mode 100644 scripts/fasttask-today-contract.mjs create mode 100644 scripts/fastui-browser.mjs create mode 100644 server/fasttask-today-client.mjs create mode 100644 server/fasttask-today-client.test.mjs create mode 100644 src/FASTUI.md create mode 100644 src/color-mode.ts diff --git a/.env.example b/.env.example index 96e83e1..3839932 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,13 @@ FASTWRITE_URL=http://127.0.0.1:3003 FASTWRITE_PUBLIC_URL=http://127.0.0.1:3003 FASTWRITE_API_KEY= FASTTASK_URL=http://127.0.0.1:10000 +# Independent server-to-server identity for today (all three or none). +# Register audience fasttask-api and scope integrations:fastresearch in FastCAS. +FASTTASK_SERVICE_ISSUER= +FASTTASK_SERVICE_CLIENT_ID= +FASTTASK_SERVICE_CLIENT_SECRET= +FASTTASK_SERVICE_ALLOW_LOOPBACK_HTTP=false +# Legacy today path when no service settings exist; Task must enable its legacy switch. FASTTASK_API_KEY=replace-with-fasttask-ingress-key # Optional; set only when FastTask provides account lifecycle endpoints. FASTTASK_PROVISION_KEY= diff --git a/.env.production.example b/.env.production.example index 2e67aef..0088f4b 100644 --- a/.env.production.example +++ b/.env.production.example @@ -25,5 +25,9 @@ VITE_FASTPPT_URL=/fastppt/ # Public FastTask origin used to validate external SSO callbacks. FASTTASK_PUBLIC_URL=https://fasttask.example.com FASTTASK_URL=http://fasttask:10000 +# Separate from FASTRESEARCH_FASTCAS_* browser-login credentials. Set all three. +FASTTASK_SERVICE_ISSUER= +FASTTASK_SERVICE_CLIENT_ID= +FASTTASK_SERVICE_CLIENT_SECRET= # Must match FASTTASK_FASTRESEARCH_SSO_SECRET in FastTask and contain at least 32 characters. FASTRESEARCH_FASTTASK_SSO_SECRET=replace-with-a-random-secret-of-at-least-32-characters diff --git a/.github/scripts/ci-node.sh b/.github/scripts/ci-node.sh new file mode 100644 index 0000000..e1802a4 --- /dev/null +++ b/.github/scripts/ci-node.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +npm test +npm run build +test -f dist/index.html diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 7f14963..314a38a 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -49,8 +49,7 @@ jobs: VITE_FASTLAB_URL: "" run: | npm ci - npm run build - test -f dist/index.html + bash .github/scripts/ci-node.sh deploy: needs: [secrets-gate, build] diff --git a/.github/workflows/tag-ci.yml b/.github/workflows/tag-ci.yml index 31a7c10..4ed6448 100644 --- a/.github/workflows/tag-ci.yml +++ b/.github/workflows/tag-ci.yml @@ -2,7 +2,9 @@ name: Tag CI FastResearch on: push: + branches: [main] tags: ['v*'] + pull_request: workflow_dispatch: permissions: @@ -14,15 +16,4 @@ concurrency: jobs: validate: - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: npm - - run: npm ci - - run: npm test - - run: npm run build - - run: test -f dist/index.html + uses: FastR-D/.github/.github/workflows/node-npm-check.yml@98eac6706014d070c5563eb75e3899611507f176 diff --git a/Dockerfile b/Dockerfile index 878f064..52b1de0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,5 @@ FROM node:22-alpine AS build WORKDIR /app -COPY --from=fastcas-sdk / /FastCAS/sdk/typescript -RUN npm --prefix /FastCAS/sdk/typescript ci && npm --prefix /FastCAS/sdk/typescript run build COPY package*.json ./ RUN npm ci COPY . . @@ -26,7 +24,6 @@ ENV PORT=8787 ENV HOST=0.0.0.0 ENV FASTRESEARCH_DATA_DIR=/app/data COPY --from=build /app/package*.json ./ -COPY --from=build /FastCAS/sdk/typescript /FastCAS/sdk/typescript COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/server ./server COPY --from=build /app/dist ./dist diff --git a/README.md b/README.md index 4830795..527929f 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ FASTRESEARCH_CORS_ORIGINS= FastTask 可使用 `FASTTASK_INGEST_KEY` 配置专用 `X-FastTask-Key` 发布凭证;该凭证只用于工作流发布,不影响 FastInsight 的 `FASTINSIGHT_INGEST_KEY`。 -FastResearch 回传工序完成证据时使用 `FASTTASK_URL` + `FASTTASK_API_KEY`,请求 FastTask 的 `/api/v1/integrations/fastresearch/progress`,并通过 `X-API-Key` 认证。FastTask 对应配置为 `FASTTASK_FASTRESEARCH_INGEST_KEY`;同一工序事件使用稳定 `event_id` 幂等,服务重启后会继续重试待同步事件。 +Research 已预留工序完成证据的 outbox 和 `/api/v1/integrations/fastresearch/progress` 客户端,使用稳定 `event_id` 重试;当前 Task 接收端仅有 `/today`,尚未实现 progress/tasks/accounts。部署时不能把这些预留调用视为已可用的跨产品能力,today 的独立服务身份也不授权这些写操作。 - `POST /api/content/reading/publish` - FastRead 推送阅读记录 - `POST /api/insight/publish` - FastInsight 推送研究信号 @@ -209,7 +209,7 @@ FastTask 使用 `fasttask-dispatch-v1` 协议版本发布任务: | `FASTTASK_FASTRESEARCH_PUBLIC_URL` | FastTask | 浏览器访问 FastResearch 的公开地址 | | `FASTTASK_FASTRESEARCH_SSO_SECRET` | FastTask | 与 FastResearch 侧一致 | -FastResearch 工作台通过 `GET /api/fasttask/today` 展示当前成员在 FastTask 的当天任务,`GET /api/workflow/team` 同时返回每位成员当天的 FastTask 任务进度。FastResearch 服务端使用 `FASTTASK_URL` + `FASTTASK_API_KEY` 调用 FastTask 的 `/api/v1/integrations/fastresearch/today`;FastTask 侧对应配置为 `FASTTASK_FASTRESEARCH_INGEST_KEY`,两边的 Key 必须一致。没有收到任务时,工作台显示「未接受到任务」。 +FastResearch 工作台通过 `GET /api/fasttask/today` 展示当前成员在 FastTask 的当天任务,`GET /api/workflow/team` 同时返回每位成员当天的 FastTask 任务进度。服务端配置 `FASTTASK_URL` 及 `FASTTASK_SERVICE_ISSUER`、`FASTTASK_SERVICE_CLIENT_ID`、`FASTTASK_SERVICE_CLIENT_SECRET`,通过已发行 FastCAS SDK 获取 scope 为 `integrations:fastresearch` 的服务 token。该身份独立于浏览器登录,FastCAS 注册须签发 Task audience(默认 `fasttask-api`);Task 侧还须允许此 client 和对应 accountId 的 receiver refs。仅本地 HTTP issuer 需要 `FASTTASK_SERVICE_ALLOW_LOOPBACK_HTTP=true`。现有 `FASTTASK_API_KEY`(或 `FASTTASK_PROVISION_KEY`)部署仍可查询,但要求 Task 显式启用 `FASTTASK_FASTRESEARCH_LEGACY_INGEST_KEY_ENABLED` 且共享 Key 一致;服务身份配置不全或获取 token 失败不会降级到 Key。没有收到任务时,工作台显示「未接受到任务」。接收合同见 `contracts/fastresearch-today/v1/`;Task 当前未实现 tasks/progress/accounts 接收端。 本机以 `FASTTASK_ENV=production` 运行且公开地址为回环 HTTP 时,FastTask 还需设置 `FASTTASK_FASTRESEARCH_LOOPBACK_HTTP=true`。完整接口与安全规则见 [docs/key-login.md](docs/key-login.md)。 diff --git a/contracts/fastresearch-today/v1/README.md b/contracts/fastresearch-today/v1/README.md new file mode 100644 index 0000000..824a7b1 --- /dev/null +++ b/contracts/fastresearch-today/v1/README.md @@ -0,0 +1,21 @@ +# Vendored Task-owned today fixture + +Source: FastR-D/FastTask `contracts/fastresearch-today/v1/cases.json`. +This local copy supports a clean single-repository test run. Upgrade it together +with the receiver-owned contract; it is not a separately authored protocol. +The shared fixture describes the real today endpoint only. Research's historical +tasks/progress/account methods remain reserved against Task main 8e91d76. + +`npm test` exercises the production today factory and installed registry SDK. +`node scripts/fasttask-today-contract.mjs` requires an explicitly configured local +test receiver and issuer, and asserts both service and legacy paths. The Task +wire test can launch that probe with a disposable signed issuer and real Task +HTTP server. The probe also starts Research's actual server with only service +credentials, logs in a local Key member, and calls its workspace today route +twice. It asserts received=true, the full nonempty three-item projection, +completion/progress counts, plan metadata and workspace links against +`research_today`. The direct client also checks the known and unknown receivers' +entire wire responses. Task seeds actual account mappings and domain rows; +`$today` is the current UTC date, not a product configuration option. +Task checks that the production client reuses its token. It must never +target production. diff --git a/contracts/fastresearch-today/v1/cases.json b/contracts/fastresearch-today/v1/cases.json new file mode 100644 index 0000000..614a970 --- /dev/null +++ b/contracts/fastresearch-today/v1/cases.json @@ -0,0 +1,45 @@ +{ + "request": {"receiver_refs": [" account-a ", "account-a", "", "missing-account"]}, + "normalized_refs": ["account-a", "missing-account"], + "response": {"members": [ + { + "receiver_ref": "account-a", "found": true, + "user_id": "task-user-a", "display_name": "Research member", + "local_date": "$today", "timezone": "UTC", "plan_id": "today-plan-a", + "plan_status": "active", "plan_revision": 3, "updated_at": "2026-11-10T00:00:00Z", + "items": [ + {"id": "item-read", "task_id": "task-read", "kind": "core", "title": "fastread: Read evidence", "commitment": "Extract two claims", "minimum_action": "Read the abstract", "status": "in_progress", "target_minutes": 30, "position": 1, "workspace": {"system": "fastread", "label": "FastRead", "url": "https://fastrd.io/read/"}}, + {"id": "item-write", "task_id": "task-write", "kind": "core", "title": "fastwrite: Draft findings", "commitment": "Save a findings draft", "minimum_action": "Write one paragraph", "status": "satisfied", "completion_type": "result", "completion_summary": "Draft saved", "target_minutes": 45, "position": 2, "workspace": {"system": "fastwrite", "label": "FastWrite", "url": "https://fastrd.io/write/"}}, + {"id": "item-review", "task_id": "task-review", "kind": "core", "title": "Review evidence", "commitment": "Check the claim list", "minimum_action": "Open the checklist", "status": "planned", "target_minutes": 45, "position": 3} + ], + "progress": {"total": 3, "completed": 1, "in_progress": 1, "planned": 1, "skipped": 0} + }, + {"receiver_ref": "missing-account", "found": false, "items": [], "progress": {"total": 0, "completed": 0, "in_progress": 0, "planned": 0, "skipped": 0}} + ]}, + "research_today": { + "received": true, "date": "$today", "timezone": "UTC", "planStatus": "active", "planRevision": 3, "updatedAt": "2026-11-10T00:00:00Z", + "items": [ + {"id": "item-read", "taskId": "task-read", "kind": "core", "title": "fastread: Read evidence", "commitment": "Extract two claims", "minimumAction": "Read the abstract", "status": "in_progress", "completionType": "", "completionSummary": "", "targetMinutes": 30, "position": 1, "workspace": {"system": "fastread", "label": "FastRead", "url": "https://fastrd.io/read/"}}, + {"id": "item-write", "taskId": "task-write", "kind": "core", "title": "fastwrite: Draft findings", "commitment": "Save a findings draft", "minimumAction": "Write one paragraph", "status": "satisfied", "completionType": "result", "completionSummary": "Draft saved", "targetMinutes": 45, "position": 2, "workspace": {"system": "fastwrite", "label": "FastWrite", "url": "https://fastrd.io/write/"}}, + {"id": "item-review", "taskId": "task-review", "kind": "core", "title": "Review evidence", "commitment": "Check the claim list", "minimumAction": "Open the checklist", "status": "planned", "completionType": "", "completionSummary": "", "targetMinutes": 45, "position": 3, "workspace": null} + ], + "progress": {"total": 3, "completed": 1, "inProgress": 1, "planned": 1, "skipped": 0}, "error": "" + }, + "auth_cases": [ + {"name": "service", "status": 200}, + {"name": "legacy", "status": 200}, + {"name": "mixed", "status": 400}, + {"name": "missing", "status": 401}, + {"name": "wrong-scope", "status": 401}, + {"name": "wrong-audience", "status": 401}, + {"name": "wrong-issuer", "status": 401}, + {"name": "wrong-client", "status": 401}, + {"name": "non-service", "status": 401}, + {"name": "wrong-subject", "status": 401}, + {"name": "expired", "status": 401}, + {"name": "wrong-signature", "status": 401}, + {"name": "wrong-token-use", "status": 401}, + {"name": "denied-receiver", "status": 403}, + {"name": "service-disabled", "status": 503} + ] +} diff --git a/docker-compose.yml b/docker-compose.yml index ec4d0f5..db2b64f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,8 +2,6 @@ services: fastresearch: build: context: . - additional_contexts: - fastcas-sdk: ../FastCAS/sdk/typescript args: VITE_API_URL: ${VITE_API_URL:-} VITE_READ_URL: ${VITE_READ_URL:-} diff --git a/docs/FASTCAS.md b/docs/FASTCAS.md index bb81ace..ce1af4a 100644 --- a/docs/FASTCAS.md +++ b/docs/FASTCAS.md @@ -4,7 +4,7 @@ 四项配置必须一起提供:`FASTRESEARCH_FASTCAS_ISSUER`、`FASTRESEARCH_FASTCAS_CLIENT_ID`、`FASTRESEARCH_FASTCAS_CLIENT_SECRET`、`FASTRESEARCH_FASTCAS_REDIRECT_URI`。回调为本实例 `/api/auth/fastcas/callback`;scope 为 `openid profile email`;事件端点为 `/api/auth/fastcas/events`。开发环回 HTTP 可设置 `FASTRESEARCH_FASTCAS_ALLOW_LOOPBACK_HTTP=true`。未配置时不导入 SDK,不访问 FastCAS。 -当前 SDK 是本地 `file:../FastCAS/sdk/typescript` 依赖,需要先构建 SDK,再安装本项目依赖。锁文件已更新;网络恢复后 `npm ci --no-audit --no-fund` 完成,`npm run build`(TypeScript + Vite)通过。 +SDK 使用正式发行包 `@fastrd/fastcas@0.1.1`,由根 package.json 与 package-lock.json 精确锁定。从本仓根目录执行 `npm ci --no-audit --no-fund`,随后 `npm run build`(TypeScript + Vite);安装与生产构建均不需要相邻 FastCAS checkout 或预构建 SDK。 FastCAS 认证操作要求页面 Origin 与回调 Origin 完全一致;部署采用前端/API 同源。回调仅接收发起浏览器的 HttpOnly 绑定 Cookie 和原项目会话 Cookie,使用 SDK 的 state/nonce/PKCE 验证。登录结果通过 HttpOnly 项目会话返回,前端经既有 `/api/content/me` 恢复项目会话,不接收上游 token。 @@ -12,7 +12,7 @@ FastCAS 认证操作要求页面 Origin 与回调 Origin 完全一致;部署 API 前缀 `/api/auth/fastcas`:GET `available/login/callback/status`;POST `link/reconcile/revoke/events`。link/revoke 的 JSON body 为 `{key}`。 -容器本地 SDK 构建上下文:`docker build --build-context fastcas-sdk=../FastCAS/sdk/typescript -t fastresearch .`。Dockerfile 已更新,容器构建尚未验证。 +容器仅使用本仓上下文:`docker build -t fastresearch .`。Dockerfile 的 `npm ci` 安装同一 registry 锁,运行层直接复制已安装的 node_modules;不再需要 SDK named context。Compose 同样只用 `context: .`:部署前准备 `.env.production` 后执行 `docker compose build fastresearch`。镜像实构结果与宿主 Docker daemon 阻碍见根实施记录;不能用宿主 npm 构建成功代替镜像验收。 验证现状:`node --test server/*.test.mjs` 7 项通过,涵盖原服务真实 HTTP 生命周期、关闭 CAS 的接口、迁移和服务层。完整前端类型检查及生产构建通过;真实 Go/PostgreSQL 提供方 → Node SDK → FastResearch HTTP 服务联调已通过。新增真实 Chrome 移动端契约:项目页面 Key 登录、显式绑定、原生 FastCAS 授权确认和回调、独立浏览器 FastCAS 登录均回到同一账号及研究笔记;浏览器解绑后 CAS 会话失效而 Key 会话与内容可用,同时修复登录后页头横向溢出。FastCAS 登录页的 CSP 现允许本次已登记的精确回调源,浏览器跨端口自动跳转在 race 模式连续三次通过;生产域名流程仍待验收。全局退出和身份停用的标准退出通知已通过真实跨进程投递。 diff --git a/docs/api.md b/docs/api.md index 9ff3873..e55751c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -216,7 +216,7 @@ Authorization: Bearer <成员会话> ### 3.2.2 FastTask 当日任务 -`GET /api/fasttask/today` 返回当前成员在 FastTask 的当天计划。FastResearch 服务端使用 `FASTTASK_URL` + `FASTTASK_API_KEY` 调用 FastTask 的 `POST /api/v1/integrations/fastresearch/today`;FastTask 侧校验 `FASTTASK_FASTRESEARCH_INGEST_KEY`,并按 `receiver_ref`(即 `accountId`)找到对应 FastTask 用户。 +`GET /api/fasttask/today` 返回当前成员在 FastTask 的当天计划。FastResearch 服务端使用 `FASTTASK_URL` 和独立的 `FASTTASK_SERVICE_ISSUER/CLIENT_ID/CLIENT_SECRET` 配置,通过 FastCAS SDK 的 client_credentials 获取 `integrations:fastresearch` scope 服务 token,调用 Task 的 `POST /api/v1/integrations/fastresearch/today`。Task 校验 audience(默认 `fasttask-api`)、client allowlist 及 `receiver_ref`(即 `accountId`)授权。未配置服务身份的既有 Key 部署需要 Task 显式打开 legacy ingest 开关;失败的服务认证不会降级。wire 合同见 `contracts/fastresearch-today/v1/`。 ```json { diff --git a/package-lock.json b/package-lock.json index 0240578..4acd993 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,9 @@ "name": "fastresearch-console", "version": "0.1.0", "dependencies": { - "@fastrd/fastcas": "file:../FastCAS/sdk/typescript", + "@fastrd/fastcas": "0.1.1", + "@fastrd/ui-foundation": "https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz", + "@fastrd/ui-react": "https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-react-0.1.0.tgz", "lucide-react": "^0.468.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -35,21 +37,6 @@ "node": ">=22.13" } }, - "../FastCAS/sdk/typescript": { - "name": "@fastrd/fastcas", - "version": "0.1.0", - "dependencies": { - "jose": "6.2.12", - "openid-client": "6.8.8" - }, - "devDependencies": { - "@types/node": "22.14.0", - "typescript": "5.9.3" - }, - "engines": { - "node": ">=22" - } - }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -945,8 +932,36 @@ } }, "node_modules/@fastrd/fastcas": { - "resolved": "../FastCAS/sdk/typescript", - "link": true + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@fastrd/fastcas/-/fastcas-0.1.1.tgz", + "integrity": "sha512-bp/PsVHuJSWiTdnL4yT4SWuNItwpKDPIvQSHiZiMt1CSHxzE1cDDpV6m5H64L+K0wfcI1GjTKbUaDCv2kJPnlQ==", + "license": "MIT", + "dependencies": { + "jose": "6.2.12", + "openid-client": "6.8.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@fastrd/ui-foundation": { + "version": "0.1.0", + "resolved": "https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz", + "integrity": "sha512-5O4s9BXXHXWk7GbzPfMYX6HjNi0DKryfWLRPYMZINNb4ew3arjCKbJvxFaSvqcCvhHxmrf8OZDg9GPmfvdnRtw==", + "license": "MIT" + }, + "node_modules/@fastrd/ui-react": { + "version": "0.1.0", + "resolved": "https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-react-0.1.0.tgz", + "integrity": "sha512-zsnN8Afm889TyhPX0ZQQ4hIn5Bj1EsFbEZ857CQXDgmCIOEfY3369bCFn1VEelR/EWjU5Lpyjj6D1E9lVtQ5uw==", + "license": "MIT", + "dependencies": { + "@fastrd/ui-foundation": "https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz" + }, + "peerDependencies": { + "react": "^18.3.0 || ^19.0.0", + "react-dom": "^18.3.0 || ^19.0.0" + } }, "node_modules/@humanfs/core": { "version": "0.19.2", @@ -2902,6 +2917,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3167,6 +3191,15 @@ "node": ">=0.10.0" } }, + "node_modules/oauth4webapi": { + "version": "3.8.8", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz", + "integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", @@ -3187,6 +3220,19 @@ "node": ">= 6" } }, + "node_modules/openid-client": { + "version": "6.8.8", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.8.tgz", + "integrity": "sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==", + "license": "MIT", + "dependencies": { + "jose": "^6.2.12", + "oauth4webapi": "^3.8.8" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", diff --git a/package.json b/package.json index 84fd02c..6cae50c 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,13 @@ "test:fasttask": "node --test server/fasttask-integration.test.mjs", "test:integration": "node --test server/integration.test.mjs", "test:visual": "node scripts/visual-check.mjs", + "test:fastui": "node scripts/fastui-browser.mjs", "start": "node --env-file=.env.production server/index.mjs" }, "dependencies": { - "@fastrd/fastcas": "file:../FastCAS/sdk/typescript", + "@fastrd/ui-foundation": "https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz", + "@fastrd/ui-react": "https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-react-0.1.0.tgz", + "@fastrd/fastcas": "0.1.1", "lucide-react": "^0.468.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/scripts/fasttask-today-contract.mjs b/scripts/fasttask-today-contract.mjs new file mode 100644 index 0000000..7ab9c75 --- /dev/null +++ b/scripts/fasttask-today-contract.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict' +import { createHash, scryptSync } from 'node:crypto' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { createFastTaskTodayClient } from '../server/fasttask-today-client.mjs' + +export const fixture = JSON.parse(readFileSync(new URL('../contracts/fastresearch-today/v1/cases.json', import.meta.url), 'utf8') + .replaceAll('$today', new Date().toISOString().slice(0, 10))) + +export async function probeToday(env) { + const service = createFastTaskTodayClient(env) + assert.ok(service, 'service client must be configured') + assert.deepEqual(await service.fetchToday(fixture.request.receiver_refs), fixture.response) + assert.deepEqual(await service.fetchToday(fixture.request.receiver_refs), fixture.response) + const legacy = createFastTaskTodayClient({ FASTTASK_URL: env.FASTTASK_URL, FASTTASK_API_KEY: env.FASTTASK_API_KEY }) + assert.ok(legacy, 'legacy client must be configured') + assert.deepEqual(await legacy.fetchToday(fixture.request.receiver_refs), fixture.response) + await assert.rejects(service.fetchToday(['outside']), { status: 403 }) + return { service: 'passed', legacy: 'passed', receiver_denied: 'passed' } +} + +// Run only in the disposable cross-product process: exercise the composition +// used by the workspace, including Key login and account -> receiver mapping. +async function probeResearchServer() { + const dir = mkdtempSync(path.join(tmpdir(), 'research-today-contract-')) + const key = 'fk_today-contract-key' + const passwordHash = `scrypt$fixture$${scryptSync('disposable-admin-password', 'fixture', 64).toString('hex')}` + process.env.FASTRESEARCH_DATA_DIR = dir + for (const name of ['FASTTASK_API_KEY', 'FASTTASK_PROVISION_KEY', 'FASTTASK_API_KEYS', + 'FASTRESEARCH_FASTCAS_ISSUER', 'FASTRESEARCH_FASTCAS_CLIENT_ID', 'FASTRESEARCH_FASTCAS_CLIENT_SECRET', + 'FASTWRITE_URL', 'FASTWRITE_API_KEY']) delete process.env[name] + writeFileSync(path.join(dir, 'access.json'), JSON.stringify({ + admin: { username: 'admin', passwordHash }, jwtSecret: 'disposable-contract-secret', + keys: [{ id: 'fixture-key', person: 'Fixture member', accountId: 'account-a', + keyHash: createHash('sha256').update(key).digest('hex'), keyPreview: 'fixture', expiresAt: null, revokedAt: null }], + }), { mode: 0o600 }) + let server + try { + const { startServer } = await import('../server/index.mjs') + server = await startServer(0, '127.0.0.1') + const origin = `http://127.0.0.1:${server.address().port}` + const login = await fetch(`${origin}/api/content/unlock`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }), + }) + assert.equal(login.status, 200) + const member = await login.json() + assert.equal(member.accountId, 'account-a') + const session = member.session + assert.ok(session) + for (let i = 0; i < 2; i++) { + const response = await fetch(`${origin}/api/fasttask/today`, { headers: { Authorization: `Bearer ${session}` } }) + assert.equal(response.status, 200) + assert.deepEqual(await response.json(), { today: fixture.research_today }) + } + return 'passed' + } finally { + if (server) await new Promise(resolve => server.close(resolve)) + rmSync(dir, { recursive: true, force: true }) + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + // This executable is an integration probe, never a production health check. + for (const key of ['FASTTASK_URL', 'FASTTASK_SERVICE_ISSUER']) { + const url = new URL(process.env[key]) + assert.ok(['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname), `${key} must be loopback`) + } + const result = await probeToday(process.env) + result.research_http_token_only = await probeResearchServer() + console.log(JSON.stringify(result)) +} diff --git a/scripts/fastui-browser.mjs b/scripts/fastui-browser.mjs new file mode 100644 index 0000000..faa1a14 --- /dev/null +++ b/scripts/fastui-browser.mjs @@ -0,0 +1,115 @@ +// Real built UI -> production Node server -> isolated SQLite/roster files. +// Run after npm run build: node scripts/fastui-browser.mjs . +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { mkdir, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright-core' + +const root = fileURLToPath(new URL('..', import.meta.url)) +const executable = process.argv[2] || process.env.EDGE_PATH +const output = path.resolve(process.argv[3] || 'test-results/fastui') +if (!executable) throw new Error('Supply a Chromium executable path or EDGE_PATH.') +await mkdir(output, { recursive: true }) +const data = path.join(output, 'data') +await mkdir(data, { recursive: true }) +const roster = path.join(data, 'roster.yaml') +await writeFile(roster, JSON.stringify({ directions: Array.from({ length: 12 }, (_, index) => ({ + name: `UI field ${String(index + 1).padStart(2, '0')}`, keywords: ['security', 'systems'], + owners: [{ name: 'Fixture owner', feishu_open_id: 'fixture-open-id' }], priority: index, +})), default_owner: { name: 'Fixture owner' } })) +const child = spawn(process.execPath, ['server/index.mjs'], { cwd: root, env: { + ...process.env, PORT: '0', HOST: '127.0.0.1', ADMIN_USERNAME: 'ui-review', ADMIN_PASSWORD: 'ui-local-only-password', + FASTRESEARCH_DATA_DIR: data, FASTRESEARCH_ACCOUNT_DATABASE: path.join(data, 'accounts.sqlite'), + FASTINSIGHT_ROSTER: roster, FASTRESEARCH_STATIC_DIR: path.join(root, 'dist'), + FASTTASK_URL: '', FASTTASK_API_KEY: '', FASTWRITE_URL: '', FASTWRITE_API_KEY: '', +}, stdio: ['ignore', 'pipe', 'pipe'] }) +let log = '' +child.stderr.on('data', chunk => { log += chunk }) +const origin = await new Promise((resolve, reject) => { + const deadline = setTimeout(() => reject(new Error('Research startup timed out: ' + log)), 20000) + child.once('error', reject) + child.stdout.on('data', chunk => { + log += chunk + const match = log.match(/FastResearch listening on (http:\/\/127\.0\.0\.1:\d+)/) + if (match) { clearTimeout(deadline); resolve(match[1]) } + }) +}) +const results = [] +let browser +try { + const adminResponse = await fetch(origin + '/api/admin/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'ui-review', password: 'ui-local-only-password' }) }) + assert.equal(adminResponse.status, 200) + const admin = await adminResponse.json() + let memberKey + for (let index = 0; index < 24; index++) { + const response = await fetch(origin + '/api/admin/keys', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${admin.session}` }, body: JSON.stringify({ person: `UI fixture ${index + 1}` }) }) + assert.equal(response.status, 201) + memberKey = (await response.json()).key + } + browser = await chromium.launch({ executablePath: executable, headless: true }) + for (const scheme of ['light', 'dark']) for (const width of [1440, 1024, 390]) { + const context = await browser.newContext({ colorScheme: scheme, viewport: { width, height: 960 }, reducedMotion: 'reduce' }) + const page = await context.newPage() + const errors = [], classes = new Set() + page.on('pageerror', error => errors.push(error.message)) + const snapshot = async name => { + for (const value of await page.evaluate(() => [...new Set([...document.querySelectorAll('[class]')].flatMap(element => [...element.classList]))])) classes.add(value) + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, name + ' overflow') + await page.screenshot({ path: path.join(output, `${scheme}-${width}-${name}.png`), fullPage: true }) + } + await page.goto(origin + '/workspace', { waitUntil: 'load' }) + await page.locator('.research-workspace').waitFor() + assert.equal(await page.locator('html').getAttribute('data-fastui-theme'), scheme) + await snapshot('guest') + const trigger = page.getByRole('button', { name: '管理员登录', exact: true }) + await trigger.click() + await page.locator('#admin-user').fill('ui-review') + await page.locator('#admin-pass').fill('ui-local-only-password') + await page.locator('dialog .dialog-form button[type=submit]').click() + await page.locator('.admin-key-row').nth(23).waitFor() + assert.equal(await page.locator('dialog').evaluate(element => element.matches(':modal')), true) + assert.equal(await page.locator('.admin-key-list').evaluate(element => element.scrollHeight > element.clientHeight), true) + for (let index = 0; index < 12; index++) { + await page.keyboard.press('Tab') + assert.equal(await page.evaluate(() => Boolean(document.activeElement.closest('dialog:modal'))), true) + } + await snapshot('admin-long-list') + await page.keyboard.press('Escape') + assert.equal(await trigger.evaluate(element => document.activeElement === element), true) + await page.getByRole('button', { name: '论文分发设置', exact: true }).click() + await page.locator('.roster-direction-card').nth(11).waitFor() + const firstName = page.locator('.roster-direction-card input').first() + await firstName.fill(`UI edited ${scheme} ${width}`) + await snapshot('roster') + const [saved] = await Promise.all([ + page.waitForResponse(response => response.request().method() === 'PUT' && response.url().endsWith('/api/admin/fastinsight/roster')), + page.locator('form.roster-editor button[type=submit]').click(), + ]) + assert.equal(saved.status(), 200) + assert.equal((await saved.json()).roster.directions[0].name, `UI edited ${scheme} ${width}`) + await page.keyboard.press('Escape') + await page.getByRole('button', { name: '验证个人Key', exact: true }).first().click() + await page.locator('#personal-key').fill(memberKey) + await page.locator('dialog form button[type=submit]').click() + await page.locator('.header-identity').waitFor() + await page.locator('.workspace-tabs button').filter({ hasText: '科研工具' }).click() + await page.locator('.workflow-stage').waitFor() + await snapshot('workflow-member') + await page.locator('.workspace-tabs button').filter({ hasText: 'FastInsight信箱' }).click() + await page.locator('.insight-mailbox').waitFor() + await snapshot('inbox-member') + await page.goto(origin + '/intro', { waitUntil: 'load' }) + assert.equal(await page.locator('.promo-page').getAttribute('data-fastui-theme'), 'light') + await snapshot('intro') + assert.deepEqual(errors, []) + results.push({ scheme, width, classes: [...classes].sort(), pageErrors: errors, roster: 'saved', keys: 'long list scrolls', dialog: 'native/focus cycle/Escape/restore', overflow: false }) + await context.close() + } +} finally { + await writeFile(path.join(output, 'results.json'), JSON.stringify({ results, log }, null, 2)) + await browser?.close() + child.kill() +} +console.log(JSON.stringify({ passed: results.length, output }, null, 2)) diff --git a/server/fasttask-client.mjs b/server/fasttask-client.mjs index c4596b9..5386dd3 100644 --- a/server/fasttask-client.mjs +++ b/server/fasttask-client.mjs @@ -1,4 +1,6 @@ -/** FastTask client used by FastResearch to send stage evidence and create tasks. */ +/** Reserved Task write/account integration; Task main exposes only today. + * The live today consumer uses fasttask-today-client.mjs and its own identity. + */ export class FastTaskClient { constructor(baseURL, apiKey) { this.baseURL = String(baseURL).replace(/\/$/, '') @@ -43,26 +45,6 @@ export class FastTaskClient { return data } - async fetchToday(receiverRefs) { - const response = await fetch(`${this.baseURL}/api/v1/integrations/fastresearch/today`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'X-API-Key': this.apiKey, - }, - body: JSON.stringify({ receiver_refs: receiverRefs }), - signal: AbortSignal.timeout(4000), - }) - const data = await response.json().catch(() => ({})) - if (!response.ok) { - const error = new Error(data.detail || data.error || `FastTask API error: ${response.status}`) - error.status = response.status - throw error - } - return data - } - async checkHealth() { try { const response = await fetch(`${this.baseURL}/health/ready`) diff --git a/server/fasttask-today-client.mjs b/server/fasttask-today-client.mjs new file mode 100644 index 0000000..9359d48 --- /dev/null +++ b/server/fasttask-today-client.mjs @@ -0,0 +1,57 @@ +import { FastCAS } from '@fastrd/fastcas/server' + +// This identity is deliberately independent of the browser login client and of +// the legacy write/provision clients, whose receiver APIs are still reserved. +export function createFastTaskTodayClient(env = process.env) { + const baseURL = String(env.FASTTASK_URL || env.VITE_FASTTASK_URL || '').trim().replace(/\/$/, '') + const issuer = String(env.FASTTASK_SERVICE_ISSUER || '').trim() + const clientId = String(env.FASTTASK_SERVICE_CLIENT_ID || '').trim() + const clientSecret = String(env.FASTTASK_SERVICE_CLIENT_SECRET || '').trim() + const settings = [issuer, clientId, clientSecret] + if (settings.some(Boolean) && (!baseURL || !settings.every(Boolean))) { + throw new Error('FastTask today requires FASTTASK_URL and all three FASTTASK_SERVICE connection settings') + } + if (!baseURL) return null + let authorization + if (settings.every(Boolean)) { + const sdk = new FastCAS({ issuer, clientId, clientSecret, + redirectUri: `${issuer}/console/`, timeoutSeconds: 4, + allowLoopbackHTTP: env.FASTTASK_SERVICE_ALLOW_LOOPBACK_HTTP === 'true' }) + let cached, pending + authorization = async () => { + if (!cached || cached.until <= Date.now()) { + pending ??= (async () => { + const started = Date.now() + const token = await sdk.clientCredentials(['integrations:fastresearch']) + if (!token.access_token || !Number.isFinite(token.expires_in) || token.expires_in <= 0) { + throw new Error('FastCAS service token has no usable lifetime') + } + cached = { value: token.access_token, until: started + token.expires_in * 1000 - 5000 } + })() + try { await pending } finally { pending = null } + } + return { Authorization: `Bearer ${cached.value}` } + } + } else { + // Existing installations use this key. Task must explicitly enable its + // legacy ingest switch; a failed service grant never selects this branch. + const key = String(env.FASTTASK_API_KEY || env.FASTTASK_PROVISION_KEY || '').trim() + if (!key) return null + authorization = async () => ({ 'X-API-Key': key }) + } + return { + async fetchToday(receiverRefs) { + const response = await fetch(`${baseURL}/api/v1/integrations/fastresearch/today`, { + method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...await authorization() }, + body: JSON.stringify({ receiver_refs: receiverRefs }), signal: AbortSignal.timeout(4000), + }) + const data = await response.json().catch(() => ({})) + if (!response.ok) { + const error = new Error(data.detail || data.error || `FastTask API error: ${response.status}`) + error.status = response.status + throw error + } + return data + }, + } +} diff --git a/server/fasttask-today-client.test.mjs b/server/fasttask-today-client.test.mjs new file mode 100644 index 0000000..92a4583 --- /dev/null +++ b/server/fasttask-today-client.test.mjs @@ -0,0 +1,66 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import { createFastTaskTodayClient } from './fasttask-today-client.mjs' +import { fixture, probeToday } from '../scripts/fasttask-today-contract.mjs' + +test('registry SDK client_credentials and real today client consume Task wire fixture; legacy stays live', async (t) => { + let origin, grants = 0, mode = 'valid', todayCalls = 0 + const server = createServer(async (req, res) => { + res.setHeader('Content-Type', 'application/json') + const chunks = [] + for await (const chunk of req) chunks.push(chunk) + const raw = Buffer.concat(chunks).toString() + if (req.url === '/.well-known/openid-configuration') { + res.end(JSON.stringify({ issuer: origin, authorization_endpoint: `${origin}/authorize`, token_endpoint: `${origin}/token`, jwks_uri: `${origin}/jwks`, userinfo_endpoint: `${origin}/userinfo`, response_types_supported: ['code'], subject_types_supported: ['public'], id_token_signing_alg_values_supported: ['RS256'], token_endpoint_auth_methods_supported: ['client_secret_basic'] })) + } else if (req.url === '/token') { + grants++ + const form = new URLSearchParams(raw) + assert.equal(form.get('grant_type'), 'client_credentials') + assert.equal(form.get('scope'), 'integrations:fastresearch') + const credentials = Buffer.from(req.headers.authorization.slice(6), 'base64').toString().split(':').map(decodeURIComponent) + assert.deepEqual(credentials, ['research-service', 'disposable-secret']) + if (mode === 'invalid') { + res.statusCode = 401; res.end(JSON.stringify({ error: 'invalid_client' })) + } else { + res.end(JSON.stringify({ access_token: 'disposable-service-token', token_type: 'Bearer', expires_in: mode === 'short' ? 1 : 60 })) + } + } else if (req.url === '/api/v1/integrations/fastresearch/today') { + todayCalls++ + if (req.headers.authorization) { + assert.equal(req.headers.authorization, 'Bearer disposable-service-token') + assert.equal(req.headers['x-api-key'], undefined) + } else { + assert.equal(req.headers['x-api-key'], 'disposable-key') + } + const body = JSON.parse(raw) + if (body.receiver_refs[0] === 'outside') { + res.statusCode = 403; res.end(JSON.stringify({ detail: 'receiver denied' })) + } else { + assert.deepEqual(body, fixture.request) + res.end(JSON.stringify(fixture.response)) + } + } else { res.statusCode = 404; res.end('{}') } + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + t.after(() => new Promise(resolve => server.close(resolve))) + origin = `http://127.0.0.1:${server.address().port}` + const env = { FASTTASK_URL: origin, FASTTASK_SERVICE_ISSUER: origin, FASTTASK_SERVICE_CLIENT_ID: 'research-service', FASTTASK_SERVICE_CLIENT_SECRET: 'disposable-secret', FASTTASK_SERVICE_ALLOW_LOOPBACK_HTTP: 'true', FASTTASK_API_KEY: 'disposable-key' } + assert.deepEqual(await probeToday(env), { service: 'passed', legacy: 'passed', receiver_denied: 'passed' }) + assert.equal(grants, 1, 'service token must be reused') + mode = 'short' + const short = createFastTaskTodayClient(env) + await Promise.all([short.fetchToday(fixture.request.receiver_refs), short.fetchToday(fixture.request.receiver_refs)]) + assert.equal(grants, 2, 'concurrent callers share an acquisition') + await short.fetchToday(fixture.request.receiver_refs) + assert.equal(grants, 3, 'short token is refreshed rather than used beyond expiry') + mode = 'invalid' + const failed = createFastTaskTodayClient(env), before = todayCalls + await assert.rejects(failed.fetchToday(['account-a'])) + assert.equal(todayCalls, before, 'grant failure must not fall back to the legacy key') + mode = 'valid' + await failed.fetchToday(fixture.request.receiver_refs) + assert.equal(grants, 5, 'failed acquisition is retryable on the next request') + assert.equal(createFastTaskTodayClient({ FASTTASK_URL: origin }), null) + assert.throws(() => createFastTaskTodayClient({ ...env, FASTTASK_SERVICE_CLIENT_SECRET: '' }), /all three/) +}) diff --git a/server/index.mjs b/server/index.mjs index d9abbd7..29e1e25 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -16,6 +16,7 @@ import { openWorkflowStore, getWorkflowsByAccount, getWorkflowById, getPendingEv import { receiveDispatch, startWorkflow, completeStage, formatWorkflowView } from './fasttask-dispatch.mjs' import { createFastWriteClient } from './fastwrite-client.mjs' import { createFastTaskClient } from './fasttask-client.mjs' +import { createFastTaskTodayClient } from './fasttask-today-client.mjs' import { RosterConflictError, RosterValidationError, readRoster, writeRoster } from './fastinsight-roster.mjs' import { handleFastReadProxy, isFastReadProxyPath } from './fastread-proxy.mjs' import { handleFastTaskProxy, isFastTaskProxyHost } from './fasttask-proxy.mjs' @@ -191,6 +192,7 @@ async function loadData() { fastTaskBaseURL = String(fastTaskUrl ?? '').trim() const fastTaskApiKey = process.env.FASTTASK_API_KEY fastTaskClient = createFastTaskClient(fastTaskUrl, fastTaskApiKey) + fastTaskTodayClient = createFastTaskTodayClient() const fastTaskProvisionKey = process.env.FASTTASK_PROVISION_KEY fastTaskProvisionClient = createFastTaskClient(fastTaskUrl, fastTaskProvisionKey) fastTaskClients = new Map() @@ -276,10 +278,11 @@ function startFastTaskSync() { } const FASTTASK_TODAY_CACHE_MS = 8000 +let fastTaskTodayClient = null let fastTaskTodayCache = { key: '', at: 0, byAccount: null } function fastTaskReadClient() { - return fastTaskClient ?? fastTaskProvisionClient + return fastTaskTodayClient } function emptyFastTaskToday(extra = {}) { diff --git a/src/App2.tsx b/src/App2.tsx index d1620c3..29ca4ec 100644 --- a/src/App2.tsx +++ b/src/App2.tsx @@ -1,5 +1,7 @@ import { ArrowUpRight, BookOpenText, CalendarCheck, CalendarClock, Check, CheckCircle2, ChevronDown, Circle, CircleDot, Clock, ExternalLink, FlaskConical, Inbox, KeyRound, Languages, Layers, ListTodo, LockKeyhole, LogIn, Moon, Newspaper, PenLine, Plus, RefreshCw, Save, Settings2, ShieldCheck, Sun, Terminal, Trash2, UsersRound, X } from 'lucide-react' import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Button, Dialog as UIDialog, TextField } from '@fastrd/ui-react' +import { useColorMode } from './color-mode' import { createTranslator, getInitialLocale, saveLocale, type Locale, type Translate } from './i18n' import { migrateLegacyMemberSession } from './member-session' @@ -112,8 +114,7 @@ function getWorkflowModuleName(entries: Record, module: Workflow } function App2() { - // Default to the light BlockSec-inspired blue research theme - const [dark, setDark] = useState(() => localStorage.getItem('fastresearch-theme') === 'dark') + const { dark, toggle: toggleColorMode } = useColorMode() const [locale, setLocale] = useState(getInitialLocale) const [keyOpen, setKeyOpen] = useState(false) const [casEnabled, setCasEnabled] = useState(false) @@ -191,11 +192,6 @@ function App2() { return () => { cancelled = true } }, []) - useEffect(() => { - document.documentElement.classList.toggle('dark', dark) - localStorage.setItem('fastresearch-theme', dark ? 'dark' : 'light') - }, [dark]) - useEffect(() => { const flag = new URLSearchParams(window.location.search).get('fastcas') if (!flag) return @@ -563,9 +559,9 @@ function App2() { {t('workspace.routing')} )} - + @@ -1471,15 +1467,13 @@ function FastInsightRosterEditor({ session, onAdminOpen, onSessionExpired, onToa function Dialog({ title, children, onClose, wide, className, t }: { title: string; children: React.ReactNode; onClose: () => void; wide?: boolean; className?: string; t: Translate }) { return ( -
event.target === event.currentTarget && onClose()}> -
-
- {title} - -
- {children} -
-
+ +
+ {title} + +
+ {children} +
) } @@ -1508,7 +1502,7 @@ function KeyDialog({ onClose, onUnlock, casAvailable, t }: { onClose: () => void
- +