diff --git a/.doc/audit/2026-09-20-bug-hunt-permissions-uploads.md b/.doc/audit/2026-09-20-bug-hunt-permissions-uploads.md new file mode 100644 index 00000000..7bb2c87c --- /dev/null +++ b/.doc/audit/2026-09-20-bug-hunt-permissions-uploads.md @@ -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` + +## 开放问题 + +- 无(本批范围内) diff --git a/.doc/audit/authorization-matrix-v3-full.md b/.doc/audit/authorization-matrix-v3-full.md index 646c6097..539df729 100644 --- a/.doc/audit/authorization-matrix-v3-full.md +++ b/.doc/audit/authorization-matrix-v3-full.md @@ -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 | diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 92cdb2e2..ed82f4b3 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -15,6 +15,7 @@ | 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 | in_progress | | 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 | diff --git a/.doc/ops/short-term/2026-09-20-bug-hunt-permissions-uploads.md b/.doc/ops/short-term/2026-09-20-bug-hunt-permissions-uploads.md new file mode 100644 index 00000000..2c7e8aa4 --- /dev/null +++ b/.doc/ops/short-term/2026-09-20-bug-hunt-permissions-uploads.md @@ -0,0 +1,58 @@ +--- +title: "修复系统权限设置与项目协作多项缺陷" +status: in_progress +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. [ ] `go vet` / `go test -short` / `npm test` / `npm run build` +5. [ ] 占集成槽位合回 `dev` + +## 验收标准 + +- [x] 代码与契约补齐(见 audit 笔记) +- [ ] 短测与前端构建通过 +- [ ] 合入 `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` diff --git a/deploy/postgres/goose/00055_2026-09-20-project-manage-members-permission.sql b/deploy/postgres/goose/00055_2026-09-20-project-manage-members-permission.sql new file mode 100644 index 00000000..d5f312e1 --- /dev/null +++ b/deploy/postgres/goose/00055_2026-09-20-project-manage-members-permission.sql @@ -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'; diff --git a/deploy/postgres/goose/00056_2026-09-20-project-create-idempotency.sql b/deploy/postgres/goose/00056_2026-09-20-project-create-idempotency.sql new file mode 100644 index 00000000..488f2dad --- /dev/null +++ b/deploy/postgres/goose/00056_2026-09-20-project-create-idempotency.sql @@ -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; diff --git a/internal/auth/pg_store.go b/internal/auth/pg_store.go index 564ce82b..43363e71 100644 --- a/internal/auth/pg_store.go +++ b/internal/auth/pg_store.go @@ -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 { diff --git a/internal/authz/authorizer.go b/internal/authz/authorizer.go index db9a9583..0200fb55 100644 --- a/internal/authz/authorizer.go +++ b/internal/authz/authorizer.go @@ -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 diff --git a/internal/authz/catalog.go b/internal/authz/catalog.go index ccf55be4..f9a0cdc3 100644 --- a/internal/authz/catalog.go +++ b/internal/authz/catalog.go @@ -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"}, diff --git a/internal/authz/service.go b/internal/authz/service.go index a4b5f197..0993eb26 100644 --- a/internal/authz/service.go +++ b/internal/authz/service.go @@ -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 } diff --git a/internal/httpx/envelope.go b/internal/httpx/envelope.go index 56c6fb52..976c67b8 100644 --- a/internal/httpx/envelope.go +++ b/internal/httpx/envelope.go @@ -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 diff --git a/internal/projectworkflow/errors.go b/internal/projectworkflow/errors.go index 009b7dba..2c431665 100644 --- a/internal/projectworkflow/errors.go +++ b/internal/projectworkflow/errors.go @@ -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") ) diff --git a/internal/projectworkflow/service.go b/internal/projectworkflow/service.go index c4722eec..baee578f 100644 --- a/internal/projectworkflow/service.go +++ b/internal/projectworkflow/service.go @@ -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. diff --git a/internal/projectworkflow/service_test.go b/internal/projectworkflow/service_test.go index 2bea9f7a..441f5775 100644 --- a/internal/projectworkflow/service_test.go +++ b/internal/projectworkflow/service_test.go @@ -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") + } +} + diff --git a/internal/projectworkflow/store.go b/internal/projectworkflow/store.go index 4b909857..a8bf5239 100644 --- a/internal/projectworkflow/store.go +++ b/internal/projectworkflow/store.go @@ -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 diff --git a/internal/projectworkflow/types.go b/internal/projectworkflow/types.go index 18275e41..679f6911 100644 --- a/internal/projectworkflow/types.go +++ b/internal/projectworkflow/types.go @@ -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"` } diff --git a/internal/server/auth_handlers.go b/internal/server/auth_handlers.go index daf3f93f..e5273b1e 100644 --- a/internal/server/auth_handlers.go +++ b/internal/server/auth_handlers.go @@ -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") diff --git a/internal/server/cors.go b/internal/server/cors.go index 01d4ac26..4a98ee5b 100644 --- a/internal/server/cors.go +++ b/internal/server/cors.go @@ -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() diff --git a/internal/server/handlers.go b/internal/server/handlers.go index f7d333d7..79069c3b 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -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 diff --git a/internal/server/project_handlers.go b/internal/server/project_handlers.go index 3da5d98b..ecd8e53e 100644 --- a/internal/server/project_handlers.go +++ b/internal/server/project_handlers.go @@ -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 diff --git a/internal/server/project_members_authz_test.go b/internal/server/project_members_authz_test.go index add76f46..d266abe6 100644 --- a/internal/server/project_members_authz_test.go +++ b/internal/server/project_members_authz_test.go @@ -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", diff --git a/internal/server/security_p0_test.go b/internal/server/security_p0_test.go index 1fe2326d..f9a3c53b 100644 --- a/internal/server/security_p0_test.go +++ b/internal/server/security_p0_test.go @@ -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) } diff --git a/internal/server/sse.go b/internal/server/sse.go index e079983c..efd23790 100644 --- a/internal/server/sse.go +++ b/internal/server/sse.go @@ -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 diff --git a/internal/server/stage_access_helpers.go b/internal/server/stage_access_helpers.go index e435656b..a3f6285b 100644 --- a/internal/server/stage_access_helpers.go +++ b/internal/server/stage_access_helpers.go @@ -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 { diff --git a/internal/server/task_run_auth.go b/internal/server/task_run_auth.go index 9e474f4c..83fb543c 100644 --- a/internal/server/task_run_auth.go +++ b/internal/server/task_run_auth.go @@ -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: diff --git a/web/src/App.tsx b/web/src/App.tsx index 40ff42b3..fa5d6f48 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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(); 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={ diff --git a/web/src/api/authUserMap.test.ts b/web/src/api/authUserMap.test.ts index 1efe73e4..263ca95f 100644 --- a/web/src/api/authUserMap.test.ts +++ b/web/src/api/authUserMap.test.ts @@ -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', () => { diff --git a/web/src/api/authUserMap.ts b/web/src/api/authUserMap.ts index 357be526..79959871 100644 --- a/web/src/api/authUserMap.ts +++ b/web/src/api/authUserMap.ts @@ -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, + 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; 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; 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, diff --git a/web/src/api/projectSettingsError.ts b/web/src/api/projectSettingsError.ts index cc0aa75a..030cfa36 100644 --- a/web/src/api/projectSettingsError.ts +++ b/web/src/api/projectSettingsError.ts @@ -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; } } diff --git a/web/src/api/systemAdmin.ts b/web/src/api/systemAdmin.ts index c8591ccc..1e334aab 100644 --- a/web/src/api/systemAdmin.ts +++ b/web/src/api/systemAdmin.ts @@ -48,11 +48,16 @@ export async function fetchSystemSettings(): Promise { export async function updateSystemSetting( key: string, value: SystemConfigValue, + expectedRevision?: number, ): Promise { + 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; diff --git a/web/src/api/taskRuns.ts b/web/src/api/taskRuns.ts index 406c0717..a8755dbb 100644 --- a/web/src/api/taskRuns.ts +++ b/web/src/api/taskRuns.ts @@ -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(); } diff --git a/web/src/components/admin/console/sections/RolesSection.tsx b/web/src/components/admin/console/sections/RolesSection.tsx index 122aa3e9..cb3e50e2 100644 --- a/web/src/components/admin/console/sections/RolesSection.tsx +++ b/web/src/components/admin/console/sections/RolesSection.tsx @@ -22,6 +22,12 @@ const ALL_PERMISSION_ITEMS: { desc: '允许在文档治理抽屉为交付物指派编制人、审核人与批准人', category: '项目与流程', }, + { + key: 'project:manage_members', + label: '管理项目成员', + desc: '允许添加、调整角色或移出项目成员(非项目 owner 时需单独授予)', + category: '项目与流程', + }, { key: 'perm:manage', label: '系统权限与角色管理', diff --git a/web/src/components/admin/console/sections/SettingsSection.tsx b/web/src/components/admin/console/sections/SettingsSection.tsx index c93ba25d..d9d4fc92 100644 --- a/web/src/components/admin/console/sections/SettingsSection.tsx +++ b/web/src/components/admin/console/sections/SettingsSection.tsx @@ -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); } diff --git a/web/src/components/chat/ChatColumn.tsx b/web/src/components/chat/ChatColumn.tsx index 03a35d00..d7970729 100644 --- a/web/src/components/chat/ChatColumn.tsx +++ b/web/src/components/chat/ChatColumn.tsx @@ -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 (
@@ -234,8 +237,12 @@ export function ChatColumn({ onStopStreaming={handleStopStreaming} quotes={activeQuotes} onRemoveQuote={handleRemoveQuote} - disabled={isCurrentProjectExited} - disabledMessage="您已退出该研发项目。依据受控体系审计要求,会话已转为只读留痕模式,无法继续发送消息。" + disabled={isCurrentProjectExited || chatBlockedNoProject} + disabledMessage={ + chatBlockedNoProject + ? '请先创建或加入项目后再开始对话' + : '您已退出该研发项目。依据受控体系审计要求,会话已转为只读留痕模式,无法继续发送消息。' + } />
); diff --git a/web/src/components/doc/preview/renderers/PdfViewer.tsx b/web/src/components/doc/preview/renderers/PdfViewer.tsx index c8bec3b2..d1973e1c 100644 --- a/web/src/components/doc/preview/renderers/PdfViewer.tsx +++ b/web/src/components/doc/preview/renderers/PdfViewer.tsx @@ -7,38 +7,68 @@ interface PdfViewerProps { fileName?: string; } -export const PdfViewer: React.FC = ({ fileRaw, fileUrl }) => { +export const PdfViewer: React.FC = ({ fileRaw, fileUrl, fileName }) => { const [activeUrl, setActiveUrl] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 ( diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index 601d7164..49306e75 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -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 = ({ const [leftWidth, setLeftWidth] = useState(280); const [leftCollapsed, setLeftCollapsed] = useState(false); const [rightWidth, setRightWidth] = useState(500); - const [rightCollapsed, setRightCollapsed] = useState(false); + const [rightCollapsed, setRightCollapsed] = useState(() => 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 = ({ 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 = ({ leftCollapsed={leftCollapsed} onToggleLeft={() => setLeftCollapsed(!leftCollapsed)} rightCollapsed={rightCollapsed} - onToggleRight={() => setRightCollapsed(!rightCollapsed)} + onToggleRight={toggleRightCollapsed} activeWorkflowKey={activeWorkflowKey} workflowVersion={workflowVersion} lifecycleStatus={lifecycleStatus} diff --git a/web/src/components/layout/WorkbenchView.tsx b/web/src/components/layout/WorkbenchView.tsx index 6ef9e05c..3c67f185 100644 --- a/web/src/components/layout/WorkbenchView.tsx +++ b/web/src/components/layout/WorkbenchView.tsx @@ -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); }} diff --git a/web/src/components/user/UserSettingsModal.tsx b/web/src/components/user/UserSettingsModal.tsx index ad242a26..72556caf 100644 --- a/web/src/components/user/UserSettingsModal.tsx +++ b/web/src/components/user/UserSettingsModal.tsx @@ -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: '权限安全' }, diff --git a/web/src/data/projectsStorage.ts b/web/src/data/projectsStorage.ts index 162977b6..1e682460 100644 --- a/web/src/data/projectsStorage.ts +++ b/web/src/data/projectsStorage.ts @@ -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 }; diff --git a/web/src/data/storage.ts b/web/src/data/storage.ts index 32a9ef85..b3c1a5fb 100644 --- a/web/src/data/storage.ts +++ b/web/src/data/storage.ts @@ -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(STORAGE_KEYS.RIGHT_RAIL_COLLAPSED, fallback); +} + +export function saveRightRailCollapsed(collapsed: boolean): void { + safeSetItem(STORAGE_KEYS.RIGHT_RAIL_COLLAPSED, collapsed); +} diff --git a/web/src/hooks/useAuthenticatedWorkspaceSync.ts b/web/src/hooks/useAuthenticatedWorkspaceSync.ts index f03a8c6d..976c3a5c 100644 --- a/web/src/hooks/useAuthenticatedWorkspaceSync.ts +++ b/web/src/hooks/useAuthenticatedWorkspaceSync.ts @@ -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(() => { diff --git a/web/src/hooks/useCreateProjectConfirm.ts b/web/src/hooks/useCreateProjectConfirm.ts index 293bc1fe..a7bda0df 100644 --- a/web/src/hooks/useCreateProjectConfirm.ts +++ b/web/src/hooks/useCreateProjectConfirm.ts @@ -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, diff --git a/web/src/types/projectWorkflow.ts b/web/src/types/projectWorkflow.ts index a807b34b..eb0d7d3d 100644 --- a/web/src/types/projectWorkflow.ts +++ b/web/src/types/projectWorkflow.ts @@ -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; } diff --git a/web/src/types/rbac.ts b/web/src/types/rbac.ts index c0d5e87a..ba3439e6 100644 --- a/web/src/types/rbac.ts +++ b/web/src/types/rbac.ts @@ -1,6 +1,7 @@ export type PermissionKey = | 'project:create' // 创建新项目/立项 | 'project:assign_lead' // 设置/变更文档责任人(编制/审核/批准) + | 'project:manage_members' // 管理项目成员(添加/调整/移出) | 'perm:manage' // 系统权限与角色管理配置 (进入管理中心增删改角色) | 'workflow:config_visibility' // 配置工作流默认可见性 | 'doc:override_visibility' // 配置单文档可见性覆盖 diff --git a/web/src/types/systemAdmin.ts b/web/src/types/systemAdmin.ts index 5c2272ae..e9cf1595 100644 --- a/web/src/types/systemAdmin.ts +++ b/web/src/types/systemAdmin.ts @@ -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; }