Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a6cfeefeb | ||
|
|
9f29b0badb | ||
|
|
c7ee6b825e | ||
|
|
032fdb050f | ||
|
|
6800a2af29 | ||
|
|
c8dbefe8a1 | ||
|
|
5cd22c7327 | ||
|
|
8fdcd983a6 |
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: "系统权限设置与项目协作多项缺陷排查"
|
||||
status: done
|
||||
created: 2026-09-20
|
||||
updated: 2026-09-20
|
||||
severity: high
|
||||
---
|
||||
|
||||
# 系统权限设置与项目协作多项缺陷排查
|
||||
|
||||
## 现象
|
||||
|
||||
用户报告:成员管理勾选无效、系统设置「允许编制人兼任审核批准」400、PDF 预览被拦截、任务 runs 403 刷屏、发起编制被标 NETWORK_ERROR、注册/建号 rollback、改密后刷新再强制改密、退出重登出现同名重复项目、无项目仍可对话、右侧工具栏默认展开且不记忆。
|
||||
|
||||
## 根因分析
|
||||
|
||||
| # | 根因 | 关键路径 |
|
||||
|---|------|----------|
|
||||
| 1 | CSP 缺 `frame-src`,blob iframe fallback 到 `default-src 'self'` | `internal/server/cors.go` |
|
||||
| 2 | PdfViewer 同源 iframe 直连,触发 `X-Frame-Options: DENY` 原生拦截页 | `web/src/components/doc/preview/renderers/PdfViewer.tsx` |
|
||||
| 3 | 前端未传 `expected_revision`(该 key 后端强制 CAS) | `web/src/api/systemAdmin.ts` / SettingsSection |
|
||||
| 4 | `ProjectSettingsApiError` 字段名与消费方 `httpStatus`/`errorCode` 不一致 | `web/src/api/projectSettingsError.ts` |
|
||||
| 5 | task-run view 仅放行创建者/owner;前端 403 无停止轮询 | `task_run_auth.go` / `App.tsx` |
|
||||
| 6 | `CanManageProjectMembers` 未读细粒度权限位 | `internal/authz/service.go` |
|
||||
| 7 | memStore `ListProjectsForUser` 忽略成员表 | `internal/projectworkflow/store.go` |
|
||||
| 8 | `user_role_bindings` 插入吞错 → commit rollback;handler 一律 400 | `internal/auth/pg_store.go` |
|
||||
| 9 | 嵌套 `user.mustChangePassword` OR 合并复活 + fetchMe 未回写 session | `authUserMap.ts` / WorkbenchView / useAuthenticatedWorkspaceSync |
|
||||
| 10 | `projects.name` 无唯一约束 + Create 无幂等键;logout 不清 projects 缓存 | `service.go` / migration 00056 / App logout |
|
||||
| 11 | `Project()<=0` 时跳过项目访问校验 | `handlers.go` / `sse.go` / `stage_access_helpers.go` |
|
||||
| 12 | `rightCollapsed` 默认 false 且无持久化 | `AppShell.tsx` / `storage.ts` |
|
||||
|
||||
## 严重度
|
||||
|
||||
`high`
|
||||
|
||||
## 关联整改
|
||||
|
||||
- Short-term: `.doc/ops/short-term/2026-09-20-bug-hunt-permissions-uploads.md`
|
||||
- 分支: `fix/bug-hunt-2026-09-20-permissions-and-uploads`
|
||||
|
||||
## 证据
|
||||
|
||||
- 产品决策:新增 `project:manage_members`;无项目禁止对话并置灰发送按钮;同创建人内项目名唯一 + `client_key` 幂等。
|
||||
- 迁移:`00055_...project-manage-members-permission.sql`、`00056_...project-create-idempotency.sql`
|
||||
|
||||
## 开放问题
|
||||
|
||||
- 无(本批范围内)
|
||||
@@ -43,7 +43,7 @@ AuthN column is **heuristic**; negative-test pointers filled where known.
|
||||
| POST | `/api/v1/auth/register` | `s.handleAuthRegister` | `server.go` | handler | see suite |
|
||||
| GET | `/api/v1/auth/sessions` | `s.handleAuthSessions` | `server.go` | handler | see suite |
|
||||
| DELETE | `/api/v1/auth/sessions/{id}` | `s.handleAuthSessionByID` | `server.go` | handler | see suite |
|
||||
| POST | `/api/v1/chat` | `s.handleChat` | `server.go` | session+project | see suite |
|
||||
| POST | `/api/v1/chat` | `s.handleChat` | `server.go` | session+project (ERR_NO_PROJECT if unbound + zero memberships) | see suite |
|
||||
| GET | `/api/v1/chat/categories` | `s.handleListChatCategories` | `server.go` | session+project | see suite |
|
||||
| GET | `/api/v1/chat/dates` | `s.handleListChatDates` | `server.go` | session+project | see suite |
|
||||
| GET | `/api/v1/chat/digests` | `s.handleListDailyDigests` | `server.go` | session+project | see suite |
|
||||
@@ -60,8 +60,8 @@ AuthN column is **heuristic**; negative-test pointers filled where known.
|
||||
| GET | `/api/v1/chat/search` | `s.handleChatSearch` | `server.go` | session+project | see suite |
|
||||
| GET | `/api/v1/chat/sessions` | `s.handleListChatSessions` | `server.go` | session+project | see suite |
|
||||
| POST | `/api/v1/chat/sessions` | `s.handleUpsertChatSession` | `server.go` | session+project | see suite |
|
||||
| GET | `/api/v1/chat/stream` | `s.handleChatStream` | `server.go` | session+project | see suite |
|
||||
| POST | `/api/v1/chat/stream` | `s.handleChatStream` | `server.go` | session+project | see suite |
|
||||
| GET | `/api/v1/chat/stream` | `s.handleChatStream` | `server.go` | session+project (ERR_NO_PROJECT if unbound + zero memberships) | see suite |
|
||||
| POST | `/api/v1/chat/stream` | `s.handleChatStream` | `server.go` | session+project (ERR_NO_PROJECT if unbound + zero memberships) | see suite |
|
||||
| GET | `/api/v1/chat/turns/{client_turn_id}` | `s.handleGetChatTurn` | `server.go` | session+project | see suite |
|
||||
| GET | `/api/v1/chat/unread` | `s.handleChatUnread` | `server.go` | session+project | see suite |
|
||||
| GET | `/api/v1/context/cases/recommend` | `s.handleRecommendCases` | `server.go` | handler | see suite |
|
||||
@@ -131,10 +131,10 @@ AuthN column is **heuristic**; negative-test pointers filled where known.
|
||||
| GET | `/api/v1/tasks/run/stream` | `s.handleRunTaskStream` | `server.go` | session+project | see suite |
|
||||
| POST | `/api/v1/tasks/run/stream` | `s.handleRunTaskStream` | `server.go` | session+project | see suite |
|
||||
| POST | `/api/v1/tasks/runs` | `s.handleCreateTaskRun` | `server.go` | session+project | see suite |
|
||||
| GET | `/api/v1/tasks/runs/{run_id}` | `s.handleGetTaskRun` | `server.go` | session+project | see suite |
|
||||
| POST | `/api/v1/tasks/runs/{run_id}/cancel` | `s.handleCancelTaskRun` | `server.go` | session+project | see suite |
|
||||
| GET | `/api/v1/tasks/runs/{run_id}/events` | `s.handleTaskRunEvents` | `server.go` | session+project | see suite |
|
||||
| POST | `/api/v1/tasks/runs/{run_id}/resume` | `s.handleResumeTaskRun` | `server.go` | session+project | see suite |
|
||||
| GET | `/api/v1/tasks/runs/{run_id}` | `s.handleGetTaskRun` | `server.go` | session+project member (view) | see suite |
|
||||
| POST | `/api/v1/tasks/runs/{run_id}/cancel` | `s.handleCancelTaskRun` | `server.go` | session+run owner or project owner | see suite |
|
||||
| GET | `/api/v1/tasks/runs/{run_id}/events` | `s.handleTaskRunEvents` | `server.go` | session+project member (view) | see suite |
|
||||
| POST | `/api/v1/tasks/runs/{run_id}/resume` | `s.handleResumeTaskRun` | `server.go` | session+run owner or project owner | see suite |
|
||||
| GET | `/api/v1/workflow-instances/{id}` | `s.handleGetWorkflowInstance` | `server.go` | session+project | see suite |
|
||||
| POST | `/api/v1/workflow-instances/{id}/stages/{stage}/advance` | `s.handleAdvanceStage` | `server.go` | session+project | see suite |
|
||||
| GET | `/api/v1/workflow-instances/{id}/stages/{stage}/readiness` | `s.handleGetStageReadiness` | `server.go` | session+project | see suite |
|
||||
|
||||
+11
-9
@@ -1,25 +1,27 @@
|
||||
# 当前焦点
|
||||
|
||||
**更新**: 2026-09-19
|
||||
**production_ready**: yes(门禁 B1–B4 已签;dev schema 53,test/main schema 52。不把 fallback change report 写成完整 PI)
|
||||
**更新**: 2026-09-20
|
||||
**production_ready**: yes(门禁 B1–B4 已签;dev/test schema 53,main schema 52。不把 fallback change report 写成完整 PI)
|
||||
|
||||
## 焦点
|
||||
|
||||
1. **2026-09-19 审计整改批 1–4 done**(合入 `844f757` / `08f4e0b` / `a2a49a6` / `d73bc79`)。dev 已 rebuild 到 schema 53。余项见 [long-term](./long-term/2026-09-19-audit-remainder.md)。未晋升 test/main。
|
||||
2. **Control Center 已移出本仓库**:项目进度由独立统一管理系统承担;本仓库恢复为智能体系统。见 [任务](./short-term/2026-09-19-remove-control-center.md)
|
||||
3. **rdev 模板深度 / document-author-only**:已合入 `origin/dev`;未晋升 test/main
|
||||
4. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 待合规签字
|
||||
1. **2026-09-20 dev→test 晋升完成 (rc-v1.0.0-beta.5)**:已合入 fix/audit-remainder-closeout,完成全量深度测试(Go 短测 46 包全绿、Vite 前端构建/测试通过、治理 PG 集成通过、运行时端点/安全响应头通过)。已合入 test 分支并重建测试环境服务(端口 :8081,schema 53 healthy)。
|
||||
2. **2026-09-19 审计整改批 1–4 done**(合入 `844f757` / `08f4e0b` / `a2a49a6` / `d73bc79`)。dev 已 rebuild 到 schema 53。余项见 [long-term](./long-term/2026-09-19-audit-remainder.md)。
|
||||
3. **Control Center 已移出本仓库**:项目进度由独立统一管理系统承担;本仓库恢复为智能体系统。见 [任务](./short-term/2026-09-19-remove-control-center.md)
|
||||
4. **rdev 模板深度 / document-author-only**:已合入 `origin/dev` 及 `test`
|
||||
5. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 待合规签字
|
||||
|
||||
## 并行认领
|
||||
|
||||
| owner | task | branch | worktree | mutex | status |
|
||||
|-------|------|--------|----------|-------|--------|
|
||||
| Cursor-Auto | 2026-09-20-bug-hunt-permissions-uploads | fix/bug-hunt-2026-09-20-permissions-and-uploads | - | authz+web+projectworkflow | done |
|
||||
| Cursor | audit-b1-data-consistency | fix/audit-b1-data-consistency | - | docgov+workflow+chat | done |
|
||||
| Cursor | audit-b2-authz-hardening | fix/audit-b2-authz-hardening | - | authz | done |
|
||||
| Cursor | audit-b3-sse-events | fix/audit-b3-sse-events | - | chat+events | done |
|
||||
| Cursor | audit-b4-frontend | fix/audit-b4-frontend | - | web | done |
|
||||
| Cursor-Auto | 2026-09-19-audit-remainder-closeout | fix/audit-remainder-closeout | - | audit-remainder | done |
|
||||
| Antigravity | 2026-09-20-dev-deep-test-and-promotion | feature/dev-deep-test-and-promotion | - | integration | in_progress |
|
||||
| Antigravity | 2026-09-20-dev-deep-test-and-promotion | feature/dev-deep-test-and-promotion | - | integration | done |
|
||||
| Cursor-Auto | 2026-09-19-remove-control-center | fix/remove-control-center | - | controlcenter | done |
|
||||
| Cursor-Auto | 2026-09-17-control-center-server | feature/control-center-server | - | controlcenter | cancelled |
|
||||
| Kimi | 2026-09-18-user-manual | feature/user-manual | .worktrees/feature-user-manual | docs | done |
|
||||
@@ -150,7 +152,7 @@
|
||||
|
||||
| holder | phase | since | scope | queue |
|
||||
|--------|-------|-------|-------|-------|
|
||||
| Antigravity | merging | 2026-09-20 | 合并 fix/audit-remainder-closeout 至 dev,全量深度测试,晋升至 test 并重启 test 系统 | - |
|
||||
| - | idle | 2026-09-20 | dev 深度测试全绿;已晋升 test (rc-v1.0.0-beta.5) 并重建测试环境;test 与 dev 均为 schema 53 healthy | - |
|
||||
|
||||
**phase**: `idle` | `merging` | `smoke` | `e2e`
|
||||
|
||||
@@ -161,7 +163,7 @@
|
||||
| 环境 | 端口 | schema | 状态 |
|
||||
|------|------|--------|------|
|
||||
| dev | 8080 | **53** | healthy(2026-09-19 `fix/audit-remainder-closeout` rebuild;goose `00053` `document_content_projections`;入口 `index-DOO0-Yh7.js`;`pi-agent`;events `:8088`) |
|
||||
| test | 8081 | **52** | healthy(Wave 2 rebuild 后 schema 与 dev/main 对齐;pi-agent `:18866`;events `:8089`) |
|
||||
| test | 8081 | **53** | healthy(2026-09-20 `rc-v1.0.0-beta.5` rebuild;goose `00053`;入口 `index-C5YTF8ik.js`;pi-agent `:18866`;events `:8089`) |
|
||||
| main | 8000 | **52** | healthy(`env=main` app+events 对齐;events 先 healthy 后启 app;consumer modes 全 off;Wave 1 已验证) |
|
||||
|
||||
## SSOT
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
title: "修复系统权限设置与项目协作多项缺陷"
|
||||
status: done
|
||||
created: 2026-09-20
|
||||
updated: 2026-09-20
|
||||
owner: Cursor-Auto
|
||||
branch: fix/bug-hunt-2026-09-20-permissions-and-uploads
|
||||
worktree: "-"
|
||||
mutex: authz+web+projectworkflow
|
||||
---
|
||||
|
||||
# 修复系统权限设置与项目协作多项缺陷
|
||||
|
||||
## 目标
|
||||
|
||||
修复两轮排查确认的 12 处后端/前端缺陷(CSP/PDF、系统设置 CAS、任务错误误分类、task-run 403、成员管理 RBAC、注册吞错、强制改密复发、项目重名、无项目可对话、右侧栏不记忆等)。
|
||||
|
||||
## 范围
|
||||
|
||||
- CSP `frame-src` + PDF blob 预览
|
||||
- 系统设置 `expected_revision` 全链路
|
||||
- `ProjectSettingsApiError` httpStatus/errorCode 别名
|
||||
- task-run 查看放宽为项目成员;前端 403/404 停轮询
|
||||
- `project:manage_members` 权限位(迁移 00055)
|
||||
- memStore 成员可见性
|
||||
- CreateUser 吞错 / 400·500 分类 / LooksInternal
|
||||
- 强制改密 session 嵌套字段 OR 复活
|
||||
- CreateProject `client_key` 幂等 + `(creator, name)` 唯一(迁移 00056)
|
||||
- 无项目禁止对话 + 发送按钮置灰
|
||||
- 右侧工具栏默认收起并持久化
|
||||
|
||||
## 非目标
|
||||
|
||||
- 全局项目名唯一(仅同创建人内唯一)
|
||||
- 放宽 `frame-ancestors` / `X-Frame-Options`
|
||||
|
||||
## 步骤
|
||||
|
||||
1. [x] 后端 CSP / authz / auth / projectworkflow / chat 入口
|
||||
2. [x] 前端 PDF / settings / 错误别名 / 轮询 / RBAC UI / 改密 / 建项幂等 / InputArea / AppShell
|
||||
3. [x] 迁移 00055 / 00056 + 单测
|
||||
4. [x] `go vet` / `go test -short` / `npm test` / `npm run build`
|
||||
5. [x] 占集成槽位合回 `dev`
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [x] 代码与契约补齐(见 audit 笔记)
|
||||
`- [x] 短测与前端构建通过
|
||||
`- [x] 合入 `dev`
|
||||
|
||||
## 相关链接
|
||||
|
||||
- Audit: `.doc/audit/2026-09-20-bug-hunt-permissions-uploads.md`
|
||||
- Plan: `.cursor/plans/修复系统权限设置与项目协作多项缺陷_ff3242f7.plan.md`
|
||||
|
||||
## 证据 / 备注
|
||||
|
||||
分支:`fix/bug-hunt-2026-09-20-permissions-and-uploads`
|
||||
@@ -0,0 +1,12 @@
|
||||
-- +goose Up
|
||||
-- 新增「管理项目成员」系统权限位;不默认授予既有角色,由管理员按需勾选。
|
||||
INSERT INTO system_permissions (perm_key, resource_type, action, risk_level)
|
||||
VALUES ('project:manage_members', 'project', 'manage_members', 'elevated')
|
||||
ON CONFLICT (perm_key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM system_role_permissions
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM system_permissions WHERE perm_key = 'project:manage_members'
|
||||
);
|
||||
DELETE FROM system_permissions WHERE perm_key = 'project:manage_members';
|
||||
@@ -0,0 +1,41 @@
|
||||
-- +goose Up
|
||||
-- 创建项目幂等键 + 同创建人下项目名唯一(防双击/重试重复行)。
|
||||
-- 约束粒度:同一 creator_user_id 内 name 唯一(允许不同团队同名项目)。
|
||||
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN IF NOT EXISTS client_key TEXT NULL;
|
||||
|
||||
COMMENT ON COLUMN projects.client_key IS
|
||||
'Optional client idempotency key; same creator+key returns the existing project.';
|
||||
|
||||
-- Deduplicate same-creator same-name rows before unique index (keep lowest id).
|
||||
WITH dups AS (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY creator_user_id, lower(trim(name))
|
||||
ORDER BY id ASC
|
||||
) AS rn
|
||||
FROM projects
|
||||
WHERE creator_user_id IS NOT NULL
|
||||
AND creator_user_id > 0
|
||||
AND length(trim(name)) > 0
|
||||
)
|
||||
UPDATE projects p
|
||||
SET name = p.name || ' (dup-' || p.id || ')'
|
||||
FROM dups d
|
||||
WHERE p.id = d.id AND d.rn > 1;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_projects_creator_client_key
|
||||
ON projects (creator_user_id, client_key)
|
||||
WHERE client_key IS NOT NULL AND length(trim(client_key)) > 0
|
||||
AND creator_user_id IS NOT NULL AND creator_user_id > 0;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_projects_creator_name
|
||||
ON projects (creator_user_id, lower(trim(name)))
|
||||
WHERE creator_user_id IS NOT NULL AND creator_user_id > 0
|
||||
AND length(trim(name)) > 0;
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS uq_projects_creator_name;
|
||||
DROP INDEX IF EXISTS uq_projects_creator_client_key;
|
||||
ALTER TABLE projects DROP COLUMN IF EXISTS client_key;
|
||||
@@ -160,11 +160,13 @@ func (s *PgStore) CreateUser(ctx context.Context, user *UserAccount) (*UserAccou
|
||||
}
|
||||
|
||||
if user.RoleID != "" {
|
||||
_, _ = tx.Exec(ctx, `
|
||||
if _, err = tx.Exec(ctx, `
|
||||
INSERT INTO user_role_bindings (user_id, role_id, scope_type, scope_id, assigned_by)
|
||||
VALUES ($1, $2, 'system', 0, $1)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, id, user.RoleID)
|
||||
`, id, user.RoleID); err != nil {
|
||||
return nil, fmt.Errorf("insert role binding: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
|
||||
@@ -65,6 +65,11 @@ func (a *Authorizer) enabled() bool {
|
||||
return a != nil && a.pool != nil
|
||||
}
|
||||
|
||||
// Enabled reports whether project membership checks are backed by a database pool.
|
||||
func (a *Authorizer) Enabled() bool {
|
||||
return a.enabled()
|
||||
}
|
||||
|
||||
// AddOwnerOnCreate 项目创建时写入 owner 成员关系,并默认编制职责。
|
||||
func (a *Authorizer) AddOwnerOnCreate(ctx context.Context, projectID, userID int64) error {
|
||||
duty := docduty.DutyAuthor
|
||||
|
||||
@@ -14,6 +14,7 @@ func PermissionCatalog() []PermissionCatalogEntry {
|
||||
return []PermissionCatalogEntry{
|
||||
{Key: "project:create", Resource: "project", Action: "create", RiskLevel: "normal", Description: "创建新项目"},
|
||||
{Key: "project:assign_lead", Resource: "project", Action: "assign_lead", RiskLevel: "elevated", Description: "指派项目负责人"},
|
||||
{Key: PermManageProjectMembers, Resource: "project", Action: "manage_members", RiskLevel: "elevated", Description: "管理项目成员(添加/调整/移出)"},
|
||||
{Key: PermManage, Resource: "system", Action: "manage", RiskLevel: "critical", Description: "系统权限管理"},
|
||||
{Key: "workflow:config_visibility", Resource: "workflow", Action: "config_visibility", RiskLevel: "elevated"},
|
||||
{Key: "doc:override_visibility", Resource: "document", Action: "override_visibility", RiskLevel: "elevated"},
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
)
|
||||
|
||||
const PermManage = "perm:manage"
|
||||
const PermManageProjectMembers = "project:manage_members"
|
||||
|
||||
// Service 统一系统 RBAC 与项目成员授权判定。
|
||||
type Service struct {
|
||||
@@ -90,7 +91,7 @@ func (s *Service) IsSystemAdmin(ctx context.Context, userID int64, roleID string
|
||||
return s.HasSystemPermission(ctx, userID, PermManage)
|
||||
}
|
||||
|
||||
// CanManageProjectMembers 项目 owner/创建人或系统管理员可管理成员。
|
||||
// CanManageProjectMembers 项目 owner/创建人、系统管理员,或持有 project:manage_members 的用户可管理成员。
|
||||
func (s *Service) CanManageProjectMembers(ctx context.Context, projectID, userID int64, roleID string) (bool, error) {
|
||||
if userID <= 0 || projectID <= 0 {
|
||||
return false, nil
|
||||
@@ -102,6 +103,13 @@ func (s *Service) CanManageProjectMembers(ctx context.Context, projectID, userID
|
||||
if admin {
|
||||
return true, nil
|
||||
}
|
||||
hasPerm, err := s.HasSystemPermission(ctx, userID, PermManageProjectMembers)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if hasPerm {
|
||||
return true, nil
|
||||
}
|
||||
if s.authorizer == nil {
|
||||
return !s.enabled() && userID > 0, nil
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func SafeMessage(code string) string {
|
||||
|
||||
func LooksInternal(s string) bool {
|
||||
low := strings.ToLower(s)
|
||||
needles := []string{"sql", "pq:", "postgres", "stack", "panic", "token", "password", "secret", "api_key", "bearer ", "../", "prompt"}
|
||||
needles := []string{"sql", "pq:", "pgx", "postgres", "stack", "panic", "token", "password", "secret", "api_key", "bearer ", "../", "prompt", "commit", "rollback", "transaction", "constraint", "violates"}
|
||||
for _, n := range needles {
|
||||
if strings.Contains(low, n) {
|
||||
return true
|
||||
|
||||
@@ -24,6 +24,9 @@ var (
|
||||
// ErrInvalidInput 输入参数不合法
|
||||
ErrInvalidInput = errors.New("invalid parameter: required field missing or malformed")
|
||||
|
||||
// ErrDuplicateProjectName 同一创建人下已存在同名项目
|
||||
ErrDuplicateProjectName = errors.New("duplicate project name for creator")
|
||||
|
||||
// ErrLegacySwitchBlocked 已启用实例化工作流治理,禁止旧式即时切换
|
||||
ErrLegacySwitchBlocked = errors.New("legacy workflow switch blocked: use project settings activation flow")
|
||||
)
|
||||
|
||||
@@ -49,6 +49,23 @@ func (s *Service) CreateProject(ctx context.Context, user *auth.UserAccount, in
|
||||
return nil, fmt.Errorf("%w: 项目名称为必填项", ErrInvalidInput)
|
||||
}
|
||||
|
||||
clientKey := strings.TrimSpace(in.ClientKey)
|
||||
if clientKey != "" {
|
||||
existing, err := s.store.GetProjectByClientKey(ctx, user.ID, clientKey)
|
||||
if err == nil && existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
if err != nil && err != ErrProjectNotFound {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if existing, err := s.store.FindProjectByCreatorAndName(ctx, user.ID, name); err == nil && existing != nil {
|
||||
return nil, fmt.Errorf("%w: 您已创建同名项目「%s」", ErrDuplicateProjectName, name)
|
||||
} else if err != nil && err != ErrProjectNotFound {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
code := strings.TrimSpace(in.Code)
|
||||
if code == "" {
|
||||
code = generateProjectCode(name)
|
||||
@@ -97,6 +114,7 @@ func (s *Service) CreateProject(ctx context.Context, user *auth.UserAccount, in
|
||||
ActiveWorkflowKey: "",
|
||||
WorkflowSelectionVersion: 1,
|
||||
WorkflowPlanRevision: 0,
|
||||
ClientKey: clientKey,
|
||||
}
|
||||
// initialWorkflow is catalog-validated only; activation stays on the settings path.
|
||||
|
||||
|
||||
@@ -309,3 +309,42 @@ func TestCreateSoftwareProject_NoMedicalDefaults(t *testing.T) {
|
||||
t.Fatalf("expected controlled_patch rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProject_ClientKeyIdempotentAndDuplicateName(t *testing.T) {
|
||||
svc := NewService(newMemStore(), NewDefaultCatalog())
|
||||
ctx := context.Background()
|
||||
creator := &auth.UserAccount{ID: 42, Username: "creator"}
|
||||
|
||||
first, err := svc.CreateProject(ctx, creator, CreateProjectInput{
|
||||
Name: "幂等项目",
|
||||
ClientKey: "ck-same-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first create: %v", err)
|
||||
}
|
||||
second, err := svc.CreateProject(ctx, creator, CreateProjectInput{
|
||||
Name: "幂等项目-改名也应返回同一条",
|
||||
ClientKey: "ck-same-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("idempotent create: %v", err)
|
||||
}
|
||||
if first.ID != second.ID {
|
||||
t.Fatalf("expected same project id, got %d vs %d", first.ID, second.ID)
|
||||
}
|
||||
|
||||
_, err = svc.CreateProject(ctx, creator, CreateProjectInput{Name: "幂等项目"})
|
||||
if err == nil || !errors.Is(err, ErrDuplicateProjectName) {
|
||||
t.Fatalf("expected ErrDuplicateProjectName, got %v", err)
|
||||
}
|
||||
|
||||
member := &auth.UserAccount{ID: 99, Username: "other"}
|
||||
other, err := svc.CreateProject(ctx, member, CreateProjectInput{Name: "幂等项目"})
|
||||
if err != nil {
|
||||
t.Fatalf("different creator same name should succeed: %v", err)
|
||||
}
|
||||
if other.ID == first.ID {
|
||||
t.Fatalf("different creators must get distinct projects")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +18,8 @@ type Store interface {
|
||||
CreateProject(ctx context.Context, p *Project) (*Project, error)
|
||||
GetProjectByID(ctx context.Context, id int64) (*Project, error)
|
||||
GetProjectByCode(ctx context.Context, code string) (*Project, error)
|
||||
GetProjectByClientKey(ctx context.Context, creatorUserID int64, clientKey string) (*Project, error)
|
||||
FindProjectByCreatorAndName(ctx context.Context, creatorUserID int64, name string) (*Project, error)
|
||||
ListAllProjects(ctx context.Context) ([]*Project, error)
|
||||
ListProjectsForUser(ctx context.Context, userID int64) ([]*Project, error)
|
||||
ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error)
|
||||
@@ -78,7 +81,7 @@ func (s *pgStore) CreateProject(ctx context.Context, p *Project) (*Project, erro
|
||||
problem_statement, target_users, target_outcome, product_shape,
|
||||
current_stage, environment, status, creator_user_id, lifecycle_status,
|
||||
active_workflow_key, workflow_selection_version, workflow_plan_revision,
|
||||
created_at, updated_at
|
||||
client_key, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10,
|
||||
@@ -86,10 +89,16 @@ func (s *pgStore) CreateProject(ctx context.Context, p *Project) (*Project, erro
|
||||
$16, $17, $18, $19,
|
||||
$20, $21, $22, $23, $24,
|
||||
$25, $26, $27,
|
||||
$28, $29
|
||||
NULLIF(trim($28), ''), $29, $30
|
||||
)
|
||||
RETURNING id
|
||||
`
|
||||
var clientKey any
|
||||
if strings.TrimSpace(p.ClientKey) != "" {
|
||||
clientKey = strings.TrimSpace(p.ClientKey)
|
||||
} else {
|
||||
clientKey = nil
|
||||
}
|
||||
err := s.pool.QueryRow(
|
||||
ctx, query,
|
||||
p.Code, p.Name, p.CustomerName, p.DeviceType, p.IntendedUse, p.TargetMarket,
|
||||
@@ -98,7 +107,7 @@ func (s *pgStore) CreateProject(ctx context.Context, p *Project) (*Project, erro
|
||||
p.ProblemStatement, p.TargetUsers, p.TargetOutcome, p.ProductShape,
|
||||
p.CurrentStage, p.Environment, p.Status, p.CreatorUserID, p.LifecycleStatus,
|
||||
p.ActiveWorkflowKey, p.WorkflowSelectionVersion, p.WorkflowPlanRevision,
|
||||
p.CreatedAt, p.UpdatedAt,
|
||||
clientKey, p.CreatedAt, p.UpdatedAt,
|
||||
).Scan(&p.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert project failed: %w", err)
|
||||
@@ -107,6 +116,91 @@ func (s *pgStore) CreateProject(ctx context.Context, p *Project) (*Project, erro
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) GetProjectByClientKey(ctx context.Context, creatorUserID int64, clientKey string) (*Project, error) {
|
||||
clientKey = strings.TrimSpace(clientKey)
|
||||
if creatorUserID <= 0 || clientKey == "" {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
query := `
|
||||
SELECT id, code, name, customer_name, device_type, intended_use, target_market,
|
||||
COALESCE(product_domain, ''), COALESCE(project_type_key, ''), COALESCE(compliance_profile_key, ''),
|
||||
COALESCE(governance_profile, ''), COALESCE(team_mode, ''), COALESCE(execution_mode, ''),
|
||||
COALESCE(data_sensitivity, ''), COALESCE(network_policy, ''), COALESCE(budget_class, ''),
|
||||
COALESCE(problem_statement, ''), COALESCE(target_users, ''), COALESCE(target_outcome, ''),
|
||||
COALESCE(product_shape, ''),
|
||||
current_stage, environment, status, COALESCE(creator_user_id, 1),
|
||||
COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''),
|
||||
COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0),
|
||||
active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at,
|
||||
COALESCE(client_key, '')
|
||||
FROM projects
|
||||
WHERE creator_user_id = $1 AND client_key = $2
|
||||
LIMIT 1
|
||||
`
|
||||
p := &Project{}
|
||||
err := s.pool.QueryRow(ctx, query, creatorUserID, clientKey).Scan(
|
||||
&p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket,
|
||||
&p.ProductDomain, &p.ProjectTypeKey, &p.ComplianceProfileKey,
|
||||
&p.GovernanceProfile, &p.TeamMode, &p.ExecutionMode,
|
||||
&p.DataSensitivity, &p.NetworkPolicy, &p.BudgetClass,
|
||||
&p.ProblemStatement, &p.TargetUsers, &p.TargetOutcome, &p.ProductShape,
|
||||
&p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID,
|
||||
&p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision,
|
||||
&p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt,
|
||||
&p.ClientKey,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get project by client_key failed: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) FindProjectByCreatorAndName(ctx context.Context, creatorUserID int64, name string) (*Project, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if creatorUserID <= 0 || name == "" {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
query := `
|
||||
SELECT id, code, name, customer_name, device_type, intended_use, target_market,
|
||||
COALESCE(product_domain, ''), COALESCE(project_type_key, ''), COALESCE(compliance_profile_key, ''),
|
||||
COALESCE(governance_profile, ''), COALESCE(team_mode, ''), COALESCE(execution_mode, ''),
|
||||
COALESCE(data_sensitivity, ''), COALESCE(network_policy, ''), COALESCE(budget_class, ''),
|
||||
COALESCE(problem_statement, ''), COALESCE(target_users, ''), COALESCE(target_outcome, ''),
|
||||
COALESCE(product_shape, ''),
|
||||
current_stage, environment, status, COALESCE(creator_user_id, 1),
|
||||
COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''),
|
||||
COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0),
|
||||
active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at,
|
||||
COALESCE(client_key, '')
|
||||
FROM projects
|
||||
WHERE creator_user_id = $1 AND lower(trim(name)) = lower(trim($2))
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
`
|
||||
p := &Project{}
|
||||
err := s.pool.QueryRow(ctx, query, creatorUserID, name).Scan(
|
||||
&p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket,
|
||||
&p.ProductDomain, &p.ProjectTypeKey, &p.ComplianceProfileKey,
|
||||
&p.GovernanceProfile, &p.TeamMode, &p.ExecutionMode,
|
||||
&p.DataSensitivity, &p.NetworkPolicy, &p.BudgetClass,
|
||||
&p.ProblemStatement, &p.TargetUsers, &p.TargetOutcome, &p.ProductShape,
|
||||
&p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID,
|
||||
&p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision,
|
||||
&p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt,
|
||||
&p.ClientKey,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find project by creator+name failed: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) GetProjectByID(ctx context.Context, id int64) (*Project, error) {
|
||||
query := `
|
||||
SELECT id, code, name, customer_name, device_type, intended_use, target_market,
|
||||
@@ -600,6 +694,8 @@ func (s *pgStore) MarkDownstreamStale(ctx context.Context, upstreamDocID string,
|
||||
type memStore struct {
|
||||
mu sync.RWMutex
|
||||
projects map[int64]*Project
|
||||
members map[int64]map[int64]struct{} // projectID -> set of active member userIDs
|
||||
byClientKey map[string]int64 // creatorID|clientKey -> projectID
|
||||
workflows map[int64]map[string]*ProjectWorkflow
|
||||
runs map[string]*WorkflowRun
|
||||
audits []*WorkflowActivationAudit
|
||||
@@ -613,6 +709,8 @@ type memStore struct {
|
||||
func newMemStore() *memStore {
|
||||
m := &memStore{
|
||||
projects: make(map[int64]*Project),
|
||||
members: make(map[int64]map[int64]struct{}),
|
||||
byClientKey: make(map[string]int64),
|
||||
workflows: make(map[int64]map[string]*ProjectWorkflow),
|
||||
runs: make(map[string]*WorkflowRun),
|
||||
nextProjID: 1000,
|
||||
@@ -679,9 +777,67 @@ func (m *memStore) CreateProject(ctx context.Context, p *Project) (*Project, err
|
||||
|
||||
cpy := *p
|
||||
m.projects[p.ID] = &cpy
|
||||
if p.CreatorUserID > 0 {
|
||||
if m.members[p.ID] == nil {
|
||||
m.members[p.ID] = make(map[int64]struct{})
|
||||
}
|
||||
m.members[p.ID][p.CreatorUserID] = struct{}{}
|
||||
if key := strings.TrimSpace(p.ClientKey); key != "" {
|
||||
m.byClientKey[fmt.Sprintf("%d|%s", p.CreatorUserID, key)] = p.ID
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// AddProjectMember records an active member for in-memory visibility (aligns with pgStore JOIN).
|
||||
func (m *memStore) AddProjectMember(projectID, userID int64) {
|
||||
if projectID <= 0 || userID <= 0 {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.members[projectID] == nil {
|
||||
m.members[projectID] = make(map[int64]struct{})
|
||||
}
|
||||
m.members[projectID][userID] = struct{}{}
|
||||
}
|
||||
|
||||
func (m *memStore) GetProjectByClientKey(ctx context.Context, creatorUserID int64, clientKey string) (*Project, error) {
|
||||
clientKey = strings.TrimSpace(clientKey)
|
||||
if creatorUserID <= 0 || clientKey == "" {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
id, ok := m.byClientKey[fmt.Sprintf("%d|%s", creatorUserID, clientKey)]
|
||||
if !ok {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
p, ok := m.projects[id]
|
||||
if !ok {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
cpy := *p
|
||||
return &cpy, nil
|
||||
}
|
||||
|
||||
func (m *memStore) FindProjectByCreatorAndName(ctx context.Context, creatorUserID int64, name string) (*Project, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if creatorUserID <= 0 || name == "" {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
want := strings.ToLower(name)
|
||||
for _, p := range m.projects {
|
||||
if p.CreatorUserID == creatorUserID && strings.ToLower(strings.TrimSpace(p.Name)) == want {
|
||||
cpy := *p
|
||||
return &cpy, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
|
||||
func (m *memStore) GetProjectByID(ctx context.Context, id int64) (*Project, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@@ -713,6 +869,13 @@ func (m *memStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Pr
|
||||
if p.CreatorUserID == userID {
|
||||
cpy := *p
|
||||
list = append(list, &cpy)
|
||||
continue
|
||||
}
|
||||
if members, ok := m.members[p.ID]; ok {
|
||||
if _, isMember := members[userID]; isMember {
|
||||
cpy := *p
|
||||
list = append(list, &cpy)
|
||||
}
|
||||
}
|
||||
}
|
||||
return list, nil
|
||||
|
||||
@@ -38,6 +38,7 @@ type Project struct {
|
||||
ActiveWorkflowChangedBy *int64 `json:"active_workflow_changed_by,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ClientKey string `json:"client_key,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectWorkflow 项目挂载的工作流实例
|
||||
@@ -121,6 +122,8 @@ type CreateProjectInput struct {
|
||||
ProductShape string `json:"product_shape,omitempty"`
|
||||
CreationToken string `json:"creation_token,omitempty"`
|
||||
PlanHash string `json:"plan_hash,omitempty"`
|
||||
// ClientKey 可选幂等键;同一创建人重复提交返回已有项目。
|
||||
ClientKey string `json:"client_key,omitempty"`
|
||||
// Deprecated: accepted for catalog existence checks only; never activates the project.
|
||||
InitialWorkflowKey string `json:"initial_workflow_key,omitempty"`
|
||||
}
|
||||
|
||||
@@ -12,6 +12,25 @@ import (
|
||||
"chat-agent-system/internal/authz"
|
||||
)
|
||||
|
||||
// authIsInfrastructureError classifies DB/driver/tx failures that must not be
|
||||
// reported as client VALIDATION (400).
|
||||
func authIsInfrastructureError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
for _, needle := range []string{
|
||||
"insert user:", "insert credentials:", "insert role binding:",
|
||||
"commit unexpectedly", "rollback", "pgx", "pq:", "sqlstate",
|
||||
"connection refused", "connection reset", "context deadline",
|
||||
} {
|
||||
if strings.Contains(msg, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractBearerToken 从 Authorization 请求头解析 Bearer Token
|
||||
func extractBearerToken(r *http.Request) string {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
@@ -99,7 +118,11 @@ func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
resp, err := s.authSvc.Register(r.Context(), req)
|
||||
if err != nil {
|
||||
writeSafeError(w, r, http.StatusBadRequest, err.Error())
|
||||
status := http.StatusBadRequest
|
||||
if authIsInfrastructureError(err) {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
writeSafeError(w, r, status, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -255,9 +278,31 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON 请求体")
|
||||
return
|
||||
}
|
||||
if roleID := strings.TrimSpace(req.RoleID); roleID != "" && s.authzSvc != nil && s.authzSvc.Enabled() {
|
||||
roles, listErr := s.authzSvc.ListRoles(r.Context())
|
||||
if listErr != nil {
|
||||
writeSafeError(w, r, http.StatusInternalServerError, listErr.Error())
|
||||
return
|
||||
}
|
||||
found := false
|
||||
for _, role := range roles {
|
||||
if role.ID == roleID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
writeSafeError(w, r, http.StatusBadRequest, "指定的角色不存在或已停用")
|
||||
return
|
||||
}
|
||||
}
|
||||
created, err := s.authSvc.CreateUserByAdmin(r.Context(), req)
|
||||
if err != nil {
|
||||
writeSafeError(w, r, http.StatusBadRequest, err.Error())
|
||||
status := http.StatusBadRequest
|
||||
if authIsInfrastructureError(err) {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
writeSafeError(w, r, status, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
|
||||
// securityCSP keeps scripts and fonts on this origin. style-src allows inline
|
||||
// styles because KaTeX and Mermaid inject them into the page. connect-src
|
||||
// 'self' covers same-origin JSON and SSE. frame-ancestors none matches
|
||||
// X-Frame-Options DENY.
|
||||
const securityCSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
|
||||
// 'self' covers same-origin JSON and SSE. frame-src allows same-origin and
|
||||
// blob: iframes for PDF preview. frame-ancestors none matches X-Frame-Options DENY.
|
||||
const securityCSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: blob:; connect-src 'self'; frame-src 'self' blob:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
|
||||
|
||||
func applySecurityHeaders(w http.ResponseWriter) {
|
||||
h := w.Header()
|
||||
|
||||
@@ -221,6 +221,9 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
if req.ProjectID > 0 && req.ProjectID != sess.Project() {
|
||||
sess.SetProjectID(req.ProjectID)
|
||||
}
|
||||
if !s.enforceChatRequiresProject(w, r, user, sess.Project()) {
|
||||
return
|
||||
}
|
||||
if sess.Project() > 0 {
|
||||
if !s.enforceChatProjectAccess(w, r, user, sess.Project()) {
|
||||
return
|
||||
|
||||
@@ -43,6 +43,13 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"`+err.Error()+`"}`)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, projectworkflow.ErrDuplicateProjectName) {
|
||||
writeJSONResponse(w, http.StatusConflict, map[string]string{
|
||||
"error_code": "ERR_DUPLICATE_PROJECT_NAME",
|
||||
"message": "您已创建同名项目,请更换名称后重试",
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, projectworkflow.ErrWorkflowNotFound) {
|
||||
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_WORKFLOW_NOT_FOUND","message":"`+err.Error()+`"}`)
|
||||
return
|
||||
|
||||
@@ -70,6 +70,19 @@ func TestProjectMembers_CrossProjectForbidden(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectMembers_ManageMembersPermissionKeyInCatalog(t *testing.T) {
|
||||
found := false
|
||||
for _, p := range authz.PermissionCatalog() {
|
||||
if p.Key == authz.PermManageProjectMembers {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected %s in system permission catalog", authz.PermManageProjectMembers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectMembers_AnonymousReturns401(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Env: "dev",
|
||||
|
||||
@@ -170,7 +170,7 @@ func TestP0_SecurityHeaders(t *testing.T) {
|
||||
t.Fatalf("referrer = %q", got)
|
||||
}
|
||||
csp := rec.Header().Get("Content-Security-Policy")
|
||||
for _, want := range []string{"default-src 'self'", "script-src 'self'", "font-src 'self'", "frame-ancestors 'none'", "object-src 'none'"} {
|
||||
for _, want := range []string{"default-src 'self'", "script-src 'self'", "font-src 'self'", "frame-src 'self' blob:", "frame-ancestors 'none'", "object-src 'none'"} {
|
||||
if !strings.Contains(csp, want) {
|
||||
t.Fatalf("csp %q missing %s", csp, want)
|
||||
}
|
||||
|
||||
@@ -210,6 +210,9 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
|
||||
if req.ProjectID > 0 && req.ProjectID != sess.Project() {
|
||||
sess.SetProjectID(req.ProjectID)
|
||||
}
|
||||
if !s.enforceChatRequiresProject(w, r, user, sess.Project()) {
|
||||
return
|
||||
}
|
||||
if sess.Project() > 0 {
|
||||
if !s.enforceChatProjectAccess(w, r, user, sess.Project()) {
|
||||
return
|
||||
|
||||
@@ -245,6 +245,38 @@ func (s *Server) applyChatWindowStageFilter(ctx context.Context, projectID, user
|
||||
s.applyChatWindowAuthFilter(authCtx, res)
|
||||
}
|
||||
|
||||
// enforceChatRequiresProject blocks chat when the session has no project and the
|
||||
// user has no active project membership (new accounts with zero projects).
|
||||
// Only enforced when a DB-backed authorizer can list memberships; memory/stub
|
||||
// mode without project_members must not false-positive as ERR_NO_PROJECT.
|
||||
func (s *Server) enforceChatRequiresProject(w http.ResponseWriter, r *http.Request, user *auth.UserAccount, projectID int64) bool {
|
||||
if projectID > 0 || user == nil {
|
||||
return true
|
||||
}
|
||||
if s.isSystemAdminUser(r.Context(), user) {
|
||||
return true
|
||||
}
|
||||
if s.authorizer == nil || !s.authorizer.Enabled() {
|
||||
return true
|
||||
}
|
||||
ids, err := s.authorizer.ListVisibleProjectIDs(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
writeJSONResponse(w, http.StatusInternalServerError, map[string]string{
|
||||
"error_code": "ERR_INTERNAL",
|
||||
"message": "无法校验项目归属",
|
||||
})
|
||||
return false
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
writeJSONResponse(w, http.StatusForbidden, map[string]string{
|
||||
"error_code": "ERR_NO_PROJECT",
|
||||
"message": "请先创建或加入项目后再开始对话",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// enforceChatProjectAccess 项目对话入口:成员/创建者;系统管理员按既有策略放行。
|
||||
// 必须在 SSE write headers / Flush 之前调用,拒绝时写结构化 JSON 403(非 200 SSE)。
|
||||
func (s *Server) enforceChatProjectAccess(w http.ResponseWriter, r *http.Request, user *auth.UserAccount, projectID int64) bool {
|
||||
|
||||
@@ -33,12 +33,15 @@ func (s *Server) assertTaskRunAccess(w http.ResponseWriter, ctx context.Context,
|
||||
}
|
||||
isOwner := rec.UserID > 0 && rec.UserID == user.ID
|
||||
isProjectOwner := false
|
||||
isProjectMember := false
|
||||
if rec.ProjectID > 0 && s.authorizer != nil {
|
||||
isProjectOwner, _ = s.authorizer.IsProjectOwner(ctx, rec.ProjectID, user.ID)
|
||||
isProjectMember, _ = s.authorizer.IsProjectMember(ctx, rec.ProjectID, user.ID)
|
||||
}
|
||||
switch action {
|
||||
case taskRunView:
|
||||
if isOwner || isProjectOwner {
|
||||
// Align with create-run RequireProjectAccess: any active project member may view.
|
||||
if isOwner || isProjectOwner || isProjectMember {
|
||||
return true
|
||||
}
|
||||
case taskRunCancel, taskRunResume:
|
||||
|
||||
+24
-1
@@ -84,6 +84,7 @@ import {
|
||||
loadProjects,
|
||||
filterProjectSessionsForUser,
|
||||
getProjectMembership,
|
||||
resetWorkbenchStorage,
|
||||
} from './data/storage';
|
||||
import { useChatWindow } from './hooks/useChatWindow';
|
||||
import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspaceSync';
|
||||
@@ -232,6 +233,7 @@ export default function App() {
|
||||
);
|
||||
|
||||
const isCurrentProjectExited = currentMembership?.status === 'exited';
|
||||
const chatBlockedNoProject = !currentProjectId || currentProjectId <= 0 || projects.length === 0;
|
||||
|
||||
// 当前用户专属的项目会话列表 (基于项目成员身份与退出留痕过滤)
|
||||
const visibleSessions = useMemo(() => {
|
||||
@@ -355,6 +357,7 @@ export default function App() {
|
||||
const canManageProjectMembers = useMemo(() => {
|
||||
if (!currentProject) return false;
|
||||
if (hasPermission(currentUser, roles, 'perm:manage', capabilities)) return true;
|
||||
if (hasPermission(currentUser, roles, 'project:manage_members', capabilities)) return true;
|
||||
if (currentUser.id === currentProject.creatorId) return true;
|
||||
const membership = currentProject.members.find((m) => m.userId === currentUser.id);
|
||||
return membership?.role === 'creator' || membership?.role === 'owner';
|
||||
@@ -939,9 +942,26 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
if (!recoverableRunKey) return;
|
||||
let cancelled = false;
|
||||
const skipped = new Set<string>();
|
||||
const poll = async () => {
|
||||
const activeIds = recoverableRunIds.filter((id) => !skipped.has(id));
|
||||
if (activeIds.length === 0) return;
|
||||
const records = await Promise.all(
|
||||
recoverableRunIds.map((runId) => getTaskRun(runId).catch(() => null)),
|
||||
activeIds.map(async (runId) => {
|
||||
try {
|
||||
return await getTaskRun(runId);
|
||||
} catch (err) {
|
||||
const status =
|
||||
typeof err === 'object' && err !== null && ('httpStatus' in err || 'status' in err)
|
||||
? Number((err as { httpStatus?: number; status?: number }).httpStatus ?? (err as { status?: number }).status)
|
||||
: undefined;
|
||||
// Stop hammering forbidden/missing runs (console 403 spam).
|
||||
if (status === 403 || status === 404) {
|
||||
skipped.add(runId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (cancelled) return;
|
||||
if (records.some((record) => record && record.status !== 'running')) {
|
||||
@@ -1189,6 +1209,8 @@ export default function App() {
|
||||
setSession(null);
|
||||
setCapabilities([]);
|
||||
setSessionValidated(false);
|
||||
setProjects([]);
|
||||
resetWorkbenchStorage();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1547,6 +1569,7 @@ export default function App() {
|
||||
activeQuotes={activeQuotes}
|
||||
handleRemoveQuote={handleRemoveQuote}
|
||||
isCurrentProjectExited={isCurrentProjectExited}
|
||||
chatBlockedNoProject={chatBlockedNoProject}
|
||||
/>
|
||||
}
|
||||
docContent={
|
||||
|
||||
@@ -39,28 +39,25 @@ test('mapUserAccount: prefers snake_case when both present', () => {
|
||||
assert.equal(u.employeeId, 'SYS-000');
|
||||
});
|
||||
|
||||
test('mapLoginResult + mapMeResult map nested user and top-level must_change_password', () => {
|
||||
const login = mapLoginResult({
|
||||
token: 'tok-1',
|
||||
user: { ...snakeAdminUser, must_change_password: true },
|
||||
must_change_password: true,
|
||||
env: 'dev',
|
||||
});
|
||||
assert.equal(login.user.roleId, 'role-admin');
|
||||
assert.equal(login.user.employeeId, 'SYS-000');
|
||||
assert.equal(login.mustChangePassword, true);
|
||||
assert.equal(login.user.mustChangePassword, true);
|
||||
|
||||
test('mapLoginResult/mapMeResult use top-level must_change_password as sole authority', () => {
|
||||
// Nested true must not revive when top-level is false (post password-change cache).
|
||||
const me = mapMeResult({
|
||||
user: snakeAdminUser,
|
||||
user: { ...snakeAdminUser, must_change_password: true },
|
||||
must_change_password: false,
|
||||
env: 'dev',
|
||||
capabilities: ['admin:manage_users'],
|
||||
capabilities: [],
|
||||
});
|
||||
assert.equal(me.user.roleId, 'role-admin');
|
||||
assert.equal(me.user.employeeId, 'SYS-000');
|
||||
assert.equal(me.mustChangePassword, false);
|
||||
assert.deepEqual(me.capabilities, ['admin:manage_users']);
|
||||
assert.equal(me.user.mustChangePassword, false);
|
||||
|
||||
const login = mapLoginResult({
|
||||
token: 'tok-2',
|
||||
user: { ...snakeAdminUser, must_change_password: true },
|
||||
must_change_password: false,
|
||||
env: 'dev',
|
||||
});
|
||||
assert.equal(login.mustChangePassword, false);
|
||||
assert.equal(login.user.mustChangePassword, false);
|
||||
});
|
||||
|
||||
test('mapUserAccountList maps admin users list', () => {
|
||||
|
||||
@@ -58,6 +58,20 @@ export function mapUserAccountList(raw: unknown): UserAccount[] {
|
||||
return raw.map((item) => mapUserAccount(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level must_change_password is the sole authority when present.
|
||||
* Never OR-merge with nested user.mustChangePassword — that revived a cleared
|
||||
* flag after FirstLoginPasswordModal only patched the top-level field.
|
||||
*/
|
||||
function resolveMustChangePassword(
|
||||
o: Record<string, unknown>,
|
||||
nestedUser: { mustChangePassword?: boolean },
|
||||
): boolean {
|
||||
if (typeof o.must_change_password === 'boolean') return o.must_change_password;
|
||||
if (typeof o.mustChangePassword === 'boolean') return o.mustChangePassword as boolean;
|
||||
return !!nestedUser.mustChangePassword;
|
||||
}
|
||||
|
||||
/** 映射 /auth/login 响应(token + user + must_change_password + env)。 */
|
||||
export function mapLoginResult(raw: unknown): LoginResult {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
@@ -65,8 +79,7 @@ export function mapLoginResult(raw: unknown): LoginResult {
|
||||
}
|
||||
const o = raw as Record<string, unknown>;
|
||||
const user = mapUserAccount(o.user);
|
||||
const mustChangePassword =
|
||||
pickBool(o, 'must_change_password', 'mustChangePassword') || !!user.mustChangePassword;
|
||||
const mustChangePassword = resolveMustChangePassword(o, user);
|
||||
const capabilities = Array.isArray(o.capabilities)
|
||||
? (o.capabilities as unknown[]).map(String)
|
||||
: undefined;
|
||||
@@ -86,8 +99,7 @@ export function mapMeResult(raw: unknown): MeResult {
|
||||
}
|
||||
const o = raw as Record<string, unknown>;
|
||||
const user = mapUserAccount(o.user);
|
||||
const mustChangePassword =
|
||||
pickBool(o, 'must_change_password', 'mustChangePassword') || !!user.mustChangePassword;
|
||||
const mustChangePassword = resolveMustChangePassword(o, user);
|
||||
return {
|
||||
user: { ...user, mustChangePassword },
|
||||
mustChangePassword,
|
||||
|
||||
@@ -14,6 +14,10 @@ export class ProjectSettingsApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
readonly issues: ProjectSettingsIssue[];
|
||||
/** Alias for consumers that read httpStatus (e.g. useTaskRunAttempts). */
|
||||
readonly httpStatus: number;
|
||||
/** Alias for consumers that read errorCode (e.g. useTaskRunAttempts). */
|
||||
readonly errorCode: string;
|
||||
|
||||
constructor(input: {
|
||||
status: number;
|
||||
@@ -26,6 +30,8 @@ export class ProjectSettingsApiError extends Error {
|
||||
this.status = input.status;
|
||||
this.code = input.code;
|
||||
this.issues = input.issues ?? [];
|
||||
this.httpStatus = input.status;
|
||||
this.errorCode = input.code;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,11 +48,16 @@ export async function fetchSystemSettings(): Promise<SystemSetting[]> {
|
||||
export async function updateSystemSetting(
|
||||
key: string,
|
||||
value: SystemConfigValue,
|
||||
expectedRevision?: number,
|
||||
): Promise<SystemSetting> {
|
||||
const body: { value: SystemConfigValue; expected_revision?: number } = { value };
|
||||
if (expectedRevision !== undefined) {
|
||||
body.expected_revision = expectedRevision;
|
||||
}
|
||||
const resp = await authorizedFetch(`${BASE}/settings/${encodeURIComponent(key)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) throw await readError(resp, '保存设置失败');
|
||||
const data = (await resp.json()) as { setting?: SystemSetting } & SystemSetting;
|
||||
|
||||
@@ -153,7 +153,13 @@ export async function getTaskRun(runId: string) {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`get task run failed: HTTP ${res.status}`);
|
||||
const err = new Error(`get task run failed: HTTP ${res.status}`) as Error & {
|
||||
httpStatus?: number;
|
||||
status?: number;
|
||||
};
|
||||
err.httpStatus = res.status;
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@ const ALL_PERMISSION_ITEMS: {
|
||||
desc: '允许在文档治理抽屉为交付物指派编制人、审核人与批准人',
|
||||
category: '项目与流程',
|
||||
},
|
||||
{
|
||||
key: 'project:manage_members',
|
||||
label: '管理项目成员',
|
||||
desc: '允许添加、调整角色或移出项目成员(非项目 owner 时需单独授予)',
|
||||
category: '项目与流程',
|
||||
},
|
||||
{
|
||||
key: 'perm:manage',
|
||||
label: '系统权限与角色管理',
|
||||
|
||||
@@ -49,7 +49,11 @@ export const SettingsSection: React.FC = () => {
|
||||
const handleSave = async (setting: SystemSetting) => {
|
||||
setSavingKey(setting.key);
|
||||
try {
|
||||
const updated = await updateSystemSetting(setting.key, drafts[setting.key]);
|
||||
const updated = await updateSystemSetting(
|
||||
setting.key,
|
||||
drafts[setting.key],
|
||||
typeof setting.revision === 'number' ? setting.revision : undefined,
|
||||
);
|
||||
setSettings((prev) => prev.map((s) => (s.key === updated.key ? updated : s)));
|
||||
setDrafts((prev) => ({ ...prev, [updated.key]: updated.value }));
|
||||
showTip(
|
||||
@@ -57,7 +61,13 @@ export const SettingsSection: React.FC = () => {
|
||||
updated.requiresRestart ? `已保存【${updated.label || updated.key}】,重启服务后生效` : `已保存【${updated.label || updated.key}】`,
|
||||
);
|
||||
} catch (err) {
|
||||
showTip('error', err instanceof Error ? err.message : '保存设置失败');
|
||||
const status = (err as Error & { status?: number }).status;
|
||||
if (status === 409) {
|
||||
showTip('error', '设置版本冲突,请刷新后重试');
|
||||
void load();
|
||||
} else {
|
||||
showTip('error', err instanceof Error ? err.message : '保存设置失败');
|
||||
}
|
||||
} finally {
|
||||
setSavingKey(null);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ export interface ChatColumnProps {
|
||||
activeQuotes: QuotedMessage[];
|
||||
handleRemoveQuote: (index: number) => void;
|
||||
isCurrentProjectExited: boolean;
|
||||
/** When true, user has no project context and cannot send chat. */
|
||||
chatBlockedNoProject?: boolean;
|
||||
}
|
||||
|
||||
export function ChatColumn({
|
||||
@@ -87,6 +89,7 @@ export function ChatColumn({
|
||||
activeQuotes,
|
||||
handleRemoveQuote,
|
||||
isCurrentProjectExited,
|
||||
chatBlockedNoProject = false,
|
||||
}: ChatColumnProps) {
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
@@ -234,8 +237,12 @@ export function ChatColumn({
|
||||
onStopStreaming={handleStopStreaming}
|
||||
quotes={activeQuotes}
|
||||
onRemoveQuote={handleRemoveQuote}
|
||||
disabled={isCurrentProjectExited}
|
||||
disabledMessage="您已退出该研发项目。依据受控体系审计要求,会话已转为只读留痕模式,无法继续发送消息。"
|
||||
disabled={isCurrentProjectExited || chatBlockedNoProject}
|
||||
disabledMessage={
|
||||
chatBlockedNoProject
|
||||
? '请先创建或加入项目后再开始对话'
|
||||
: '您已退出该研发项目。依据受控体系审计要求,会话已转为只读留痕模式,无法继续发送消息。'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,38 +7,68 @@ interface PdfViewerProps {
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
export const PdfViewer: React.FC<PdfViewerProps> = ({ fileRaw, fileUrl }) => {
|
||||
export const PdfViewer: React.FC<PdfViewerProps> = ({ fileRaw, fileUrl, fileName }) => {
|
||||
const [activeUrl, setActiveUrl] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let createdUrl: string | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
async function loadPdf() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setActiveUrl('');
|
||||
|
||||
if (fileUrl) {
|
||||
setActiveUrl(fileUrl);
|
||||
setLoading(false);
|
||||
} else if (fileRaw) {
|
||||
try {
|
||||
createdUrl = URL.createObjectURL(fileRaw);
|
||||
let blob: Blob | null = null;
|
||||
if (fileRaw) {
|
||||
blob = fileRaw;
|
||||
} else if (fileUrl) {
|
||||
// Always fetch → blob → createObjectURL so iframe never loads a
|
||||
// same-origin URL that carries X-Frame-Options: DENY / frame-ancestors 'none'.
|
||||
const res = await fetch(fileUrl);
|
||||
if (!res.ok) {
|
||||
throw new Error(`无法获取 PDF 网络资源: ${res.statusText || res.status}`);
|
||||
}
|
||||
blob = await res.blob();
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (!blob) {
|
||||
if (fileName) {
|
||||
setError(
|
||||
`文件「${fileName}」的本地内容已释放(页面刷新后浏览器会清除内存中的 File 对象)。如需预览,请重新上传该文件。`,
|
||||
);
|
||||
} else {
|
||||
setError('未检测到有效的 PDF 文件资源');
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
createdUrl = URL.createObjectURL(blob);
|
||||
setActiveUrl(createdUrl);
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
if (cancelled) return;
|
||||
console.error('无法创建 PDF 预览链接:', err);
|
||||
setError('生成 PDF 预览地址失败');
|
||||
setError(err instanceof Error ? err.message : '生成 PDF 预览地址失败');
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
setError('未检测到有效的 PDF 文件资源');
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
void loadPdf();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (createdUrl && typeof window !== 'undefined' && typeof window.URL?.revokeObjectURL === 'function') {
|
||||
window.URL.revokeObjectURL(createdUrl);
|
||||
}
|
||||
};
|
||||
}, [fileRaw, fileUrl]);
|
||||
}, [fileRaw, fileUrl, fileName]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ChatColumn } from './ChatColumn';
|
||||
import { RightDocRail } from './RightDocRail';
|
||||
import { ProjectSession } from '../../types/workbench';
|
||||
import { UserRole, UserProfile } from '../../types/docgov';
|
||||
import { loadRightRailCollapsed, saveRightRailCollapsed } from '../../data/storage';
|
||||
|
||||
interface AppShellProps {
|
||||
sessions: ProjectSession[];
|
||||
@@ -62,12 +63,13 @@ export const AppShell: React.FC<AppShellProps> = ({
|
||||
const [leftWidth, setLeftWidth] = useState<number>(280);
|
||||
const [leftCollapsed, setLeftCollapsed] = useState<boolean>(false);
|
||||
const [rightWidth, setRightWidth] = useState<number>(500);
|
||||
const [rightCollapsed, setRightCollapsed] = useState<boolean>(false);
|
||||
const [rightCollapsed, setRightCollapsed] = useState<boolean>(() => loadRightRailCollapsed(true));
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
const narrow = window.innerWidth <= 880;
|
||||
if (narrow) {
|
||||
setLeftCollapsed(true);
|
||||
// Narrow-screen force-collapse must not overwrite the persisted preference.
|
||||
setRightCollapsed(true);
|
||||
}
|
||||
};
|
||||
@@ -76,6 +78,14 @@ export const AppShell: React.FC<AppShellProps> = ({
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
const toggleRightCollapsed = () => {
|
||||
setRightCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
saveRightRailCollapsed(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const currentSession =
|
||||
sessions.find((s) => s.sessionId === activeSessionId) || sessions[0] || {
|
||||
sessionId: 'sess-default',
|
||||
@@ -126,7 +136,7 @@ export const AppShell: React.FC<AppShellProps> = ({
|
||||
leftCollapsed={leftCollapsed}
|
||||
onToggleLeft={() => setLeftCollapsed(!leftCollapsed)}
|
||||
rightCollapsed={rightCollapsed}
|
||||
onToggleRight={() => setRightCollapsed(!rightCollapsed)}
|
||||
onToggleRight={toggleRightCollapsed}
|
||||
activeWorkflowKey={activeWorkflowKey}
|
||||
workflowVersion={workflowVersion}
|
||||
lifecycleStatus={lifecycleStatus}
|
||||
|
||||
@@ -213,7 +213,11 @@ export function WorkbenchView({
|
||||
user={currentUser}
|
||||
onPasswordChanged={() => {
|
||||
if (!session) return;
|
||||
const updated = { ...session, mustChangePassword: false };
|
||||
const updated = {
|
||||
...session,
|
||||
mustChangePassword: false,
|
||||
user: { ...session.user, mustChangePassword: false },
|
||||
};
|
||||
setSession(updated);
|
||||
authClient.saveSession(updated);
|
||||
}}
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface UserSettingsModalProps {
|
||||
const ALL_PERMISSION_DESCRIPTIONS: { key: string; name: string; desc: string; category: string }[] = [
|
||||
{ key: 'project:create', name: '创建新项目/立项', desc: '具备在协同工作台开启全新研发项目并指派阶段模板的权限', category: '项目协作' },
|
||||
{ key: 'project:assign_lead', name: '设置文档责任人', desc: '在文档治理面板为交付物指派编制人、审核人与批准人', category: '项目协作' },
|
||||
{ key: 'project:manage_members', name: '管理项目成员', desc: '添加、调整角色或移出项目成员(非项目创建人时需单独授予)', category: '项目协作' },
|
||||
{ key: 'perm:manage', name: '系统权限与角色管理', desc: '增删改全局职位角色定义与权限矩阵 (管理员独占)', category: '权限安全' },
|
||||
{ key: 'workflow:config_visibility', name: '配置工作流默认可见性', desc: '调整业务阶段中各受控交付物的基线查阅准入边界', category: '权限安全' },
|
||||
{ key: 'doc:override_visibility', name: '配置单文档可见性覆盖', desc: '针对特定核心机密交付物设定独占白名单或责任人收紧', category: '权限安全' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ProjectInfo, ProjectMember, ProjectSession } from '../types/workbench';
|
||||
import { DEFAULT_PROJECTS } from '@demo-seeds';
|
||||
|
||||
/** Must stay in sync with STORAGE_KEYS.PROJECTS in storage.ts */
|
||||
const STORAGE_KEY_PROJECTS = 'smdf_agent_workbench_v6_projects';
|
||||
|
||||
export { DEFAULT_PROJECTS };
|
||||
|
||||
@@ -32,6 +32,8 @@ export const STORAGE_KEYS = {
|
||||
DOC_CACHE: `${STORAGE_PREFIX}doc_cache`,
|
||||
ROLES: `${STORAGE_PREFIX}roles`,
|
||||
USERS: `${STORAGE_PREFIX}users_v7`,
|
||||
PROJECTS: `${STORAGE_PREFIX}projects`,
|
||||
RIGHT_RAIL_COLLAPSED: `${STORAGE_PREFIX}right_rail_collapsed`,
|
||||
};
|
||||
|
||||
export {
|
||||
@@ -361,3 +363,12 @@ export function resetWorkbenchStorage(): void {
|
||||
Object.values(STORAGE_KEYS).forEach((k) => window.localStorage.removeItem(k));
|
||||
window.localStorage.removeItem(DEMO_SEED_PURGE_KEY);
|
||||
}
|
||||
|
||||
/** Default true = collapsed (user preference for right doc rail). */
|
||||
export function loadRightRailCollapsed(fallback = true): boolean {
|
||||
return safeGetItem<boolean>(STORAGE_KEYS.RIGHT_RAIL_COLLAPSED, fallback);
|
||||
}
|
||||
|
||||
export function saveRightRailCollapsed(collapsed: boolean): void {
|
||||
safeSetItem(STORAGE_KEYS.RIGHT_RAIL_COLLAPSED, collapsed);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,17 @@ export function useAuthenticatedWorkspaceSync(input: AuthenticatedWorkspaceSyncI
|
||||
.then((me) => {
|
||||
setCapabilities(me.capabilities ?? []);
|
||||
setCurrentUser(mergeSessionUser(me.user, match));
|
||||
// Rewrite session from /auth/me so a polluted local mustChangePassword
|
||||
// (nested user flag) cannot survive refresh after a successful change.
|
||||
const nextSession = {
|
||||
...saved,
|
||||
user: { ...saved.user, ...me.user, mustChangePassword: me.mustChangePassword },
|
||||
mustChangePassword: me.mustChangePassword,
|
||||
env: me.env || saved.env,
|
||||
capabilities: me.capabilities ?? saved.capabilities ?? [],
|
||||
};
|
||||
setSession(nextSession);
|
||||
authClient.saveSession(nextSession);
|
||||
setSessionValidated(true);
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -78,8 +78,13 @@ export function useCreateProjectConfirm(input: CreateProjectConfirmInput) {
|
||||
const ensured = await ensureCreatedProjectWithMembers({
|
||||
bootstrapProjectId: createProjectBootstrapIdRef.current,
|
||||
createProject: async () => {
|
||||
const clientKey =
|
||||
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? `proj-${crypto.randomUUID()}`
|
||||
: `proj-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = await createProject({
|
||||
name: data.title,
|
||||
client_key: clientKey,
|
||||
device_type: data.deviceType || undefined,
|
||||
intended_use: data.productDomain === 'non_medical_software' ? undefined : data.description,
|
||||
product_domain: data.productDomain,
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface CreateProjectInput {
|
||||
problem_statement?: string;
|
||||
target_users?: string;
|
||||
target_outcome?: string;
|
||||
/** Idempotency key; same creator+key returns the existing project. */
|
||||
client_key?: string;
|
||||
/** @deprecated Accepted by API for catalog checks only; create dialog must not send this. */
|
||||
initial_workflow_key?: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type PermissionKey =
|
||||
| 'project:create' // 创建新项目/立项
|
||||
| 'project:assign_lead' // 设置/变更文档责任人(编制/审核/批准)
|
||||
| 'project:manage_members' // 管理项目成员(添加/调整/移出)
|
||||
| 'perm:manage' // 系统权限与角色管理配置 (进入管理中心增删改角色)
|
||||
| 'workflow:config_visibility' // 配置工作流默认可见性
|
||||
| 'doc:override_visibility' // 配置单文档可见性覆盖
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface SystemSetting {
|
||||
writable: boolean;
|
||||
requiresRestart: boolean;
|
||||
description: string;
|
||||
/** CAS revision for versioned settings (e.g. workflow.allow_author_only_approval). */
|
||||
revision?: number;
|
||||
updatedAt: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user