From 04efc50161265c343c721da6cd77dad7da78dc9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:37:14 +0800 Subject: [PATCH] Add files via upload --- config.example.yaml | 6 +- web/static/css/style.css | 2359 +++++++++++++++++++++++++++++++++++- web/static/i18n/en-US.json | 370 +++++- web/static/i18n/zh-CN.json | 370 +++++- web/static/js/auth.js | 217 +++- web/static/js/chat.js | 126 +- web/static/js/monitor.js | 150 ++- web/static/js/rbac.js | 1329 ++++++++++++++++++++ web/static/js/router.js | 9 +- web/static/js/settings.js | 10 + web/static/js/webshell.js | 2 + web/templates/index.html | 341 +++++- 12 files changed, 5208 insertions(+), 81 deletions(-) create mode 100644 web/static/js/rbac.js diff --git a/config.example.yaml b/config.example.yaml index 7d5463f2..a21e7e2b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -203,9 +203,9 @@ multi_agent: tool_search_always_visible_tools: [read_file, glob, grep, analyze_image, write_file, edit_file, execute, task, transfer_to_agent, exit, write_todos, skill, tool_search, TaskCreate, TaskGet, TaskUpdate, TaskList, record_vulnerability, list_vulnerabilities, get_vulnerability, list_knowledge_risk_types, search_knowledge_base, webshell_exec, webshell_file_list, webshell_file_read, webshell_file_write, manage_webshell_list, manage_webshell_add, manage_webshell_update, manage_webshell_delete, manage_webshell_test, batch_task_list, batch_task_get, batch_task_start, batch_task_rerun, batch_task_pause, batch_task_update_metadata, batch_task_update_schedule, batch_task_schedule_enabled, batch_task_update_task, batch_task_remove_task, batch_task_delete, batch_task_create, batch_task_add_task, http-framework-test, exec] # 后端内置常驻工具白名单(优先于 always_visible 数量策略) plantask_enable: true # P0:主代理挂载 TaskCreate/Get/Update/List 结构化任务板;需 eino_skills 可用且 skills_dir 存在 plantask_rel_dir: .eino/plantask # 任务文件相对 skills_dir,按会话分子目录:skills/.eino/plantask// - reduction_enable: true # true:大工具输出截断/落盘以控上下文;依赖与 plantask 相同的 eino local 写盘后端,无后端时不挂载 + reduction_enable: true # true:大工具输出截断/落盘以控上下文;后端会独立创建,不依赖 eino_skills 是否启用 reduction_max_length_for_trunc: 50000 # 单条工具结果超过该字符数(bytes)时截断并落盘(由 reduction 中间件处理) - reduction_max_tokens_for_clear: 160000 # 历史工具结果清理阈值(tokens),超阈值时在模型调用前清理旧结果 + reduction_max_tokens_for_clear: 60000 # 历史工具结果清理阈值(tokens),应低于 max_total_tokens * summarization_trigger_ratio reduction_root_dir: "" # 非空:截断/清理内容落盘根路径;空:使用系统临时目录下按会话隔离的默认路径 reduction_clear_exclude: [] # 不参与「清理阶段」的工具名额外列表(会与 task/transfer/exit 等内置排除项合并);需要时用 YAML 列表填写 reduction_sub_agents: true # true:子代理也挂 reduction;false:仅编排主代理使用 reduction @@ -417,4 +417,4 @@ project: # 事实关系速览段预算(从索引总预算中预留) fact_index_path_max_runes: 10000 fact_summary_max_runes: 24000 - default_inject_deprecated: false \ No newline at end of file + default_inject_deprecated: false diff --git a/web/static/css/style.css b/web/static/css/style.css index 09f40700..a2e4e87a 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -1172,7 +1172,8 @@ html[data-theme="dark"] .openapi-doc-btn:hover { padding: 0; } -.user-avatar-btn:hover { +.user-avatar-btn:hover, +.user-avatar-btn.active { background: var(--bg-tertiary); border-color: var(--accent-color); color: var(--accent-color); @@ -1188,20 +1189,87 @@ html[data-theme="dark"] .openapi-doc-btn:hover { position: absolute; top: calc(100% + 8px); right: 0; - min-width: 160px; + width: min(320px, calc(100vw - 24px)); background: var(--bg-primary); border: 1px solid var(--border-color); - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border-radius: 10px; + box-shadow: 0 16px 40px rgba(15, 23, 42, 0.18); z-index: 1000; overflow: hidden; } +.user-menu-profile { + padding: 14px; +} + +.user-menu-profile-row { + display: block; +} + +.user-menu-profile-main { + min-width: 0; +} + +.user-menu-profile-label { + margin-bottom: 3px; + color: var(--text-muted); + font-size: 0.75rem; + line-height: 1.2; +} + +.user-menu-profile-name { + color: var(--text-primary); + font-size: 0.9375rem; + font-weight: 700; + line-height: 1.35; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-menu-profile-username { + margin-top: 2px; + color: var(--text-secondary); + font-size: 0.8125rem; + line-height: 1.35; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-menu-profile-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 12px; +} + +.user-menu-profile-pill { + display: inline-flex; + align-items: center; + min-width: 0; + max-width: 100%; + min-height: 24px; + padding: 4px 8px; + border-radius: 999px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 0.75rem; + font-weight: 600; + line-height: 1.2; +} + +.user-menu-divider { + height: 1px; + background: var(--border-color); +} + .user-menu-item { display: flex; align-items: center; gap: 8px; - padding: 12px 16px; + padding: 12px 14px; cursor: pointer; color: var(--text-primary); font-size: 0.875rem; @@ -1222,6 +1290,10 @@ html[data-theme="dark"] .openapi-doc-btn:hover { flex-shrink: 0; } +html[data-theme="dark"] .user-menu-dropdown { + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.42); +} + .attack-chain-btn:not(:disabled):hover { background: var(--bg-tertiary); border: none; @@ -4362,6 +4434,7 @@ html[data-theme="dark"] .openapi-doc-btn:hover { color: var(--text-secondary); } +.login-form input[type="text"], .login-form input[type="password"] { width: 100%; padding: 12px 14px; @@ -4373,10 +4446,12 @@ html[data-theme="dark"] .openapi-doc-btn:hover { transition: border-color 0.2s, box-shadow 0.2s; } +.login-form input[type="text"]::placeholder, .login-form input[type="password"]::placeholder { color: var(--text-muted); } +.login-form input[type="text"]:focus, .login-form input[type="password"]:focus { outline: none; border-color: var(--accent-color); @@ -4948,6 +5023,22 @@ html[data-theme="dark"] .openapi-doc-btn:hover { font-size: 0.875rem; } +.progress-timeline-empty.progress-timeline-lazy-clickable { + cursor: pointer; + transition: color 0.15s ease, background-color 0.15s ease; + border-radius: 4px; +} + +.progress-timeline-empty.progress-timeline-lazy-clickable:hover { + color: var(--text-secondary); + background: var(--bg-tertiary); +} + +.progress-timeline-empty.progress-timeline-lazy-clickable:focus-visible { + outline: 2px solid var(--accent-color, #3b82f6); + outline-offset: 2px; +} + .progress-header { display: flex; justify-content: space-between; @@ -5593,6 +5684,12 @@ html[data-theme="dark"] .openapi-doc-btn:hover { background: rgba(220, 53, 69, 0.085); } +.timeline-item-tool_call.tool-call-incomplete { + border: 1px solid rgba(245, 158, 11, 0.38); + border-left: 3px solid #f59e0b; + background: rgba(245, 158, 11, 0.085); +} + .timeline-item.tool-detail-collapsible { cursor: pointer; } @@ -9643,6 +9740,674 @@ html[data-theme="dark"] .openapi-doc-btn:hover { } } +/* RBAC workspace: keep navigation stable and long resource collections bounded. */ +.platform-rbac-page > .page-content { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + background: color-mix(in srgb, var(--bg-secondary) 72%, var(--bg-primary)); +} + +.rbac-topbar { + flex: 0 0 auto; +} + +.rbac-workspace-view:not([hidden]) { + flex: 1 1 auto; + min-height: 0; +} + +.rbac-layout { + grid-template-columns: minmax(280px, 328px) minmax(0, 1fr); + align-items: stretch; + height: 100%; + overflow: hidden; +} + +.rbac-panel { + height: 100%; + overflow: hidden; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.035); +} + +.rbac-panel-users { + position: static; + height: 100%; + max-height: none; +} + +.rbac-panel-detail { + display: flex; + min-width: 0; +} + +.rbac-panel-detail .rbac-tab-panel:not([hidden]) { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +.rbac-panel-users .rbac-list, +.rbac-panel-detail .rbac-tab-panel { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--text-muted) 60%, transparent) transparent; +} + +.rbac-panel-users .rbac-list::-webkit-scrollbar, +.rbac-panel-detail .rbac-tab-panel::-webkit-scrollbar, +.rbac-resource-picker::-webkit-scrollbar, +.rbac-assignments-list::-webkit-scrollbar { + width: 6px; +} + +.rbac-panel-users .rbac-list::-webkit-scrollbar-thumb, +.rbac-panel-detail .rbac-tab-panel::-webkit-scrollbar-thumb, +.rbac-resource-picker::-webkit-scrollbar-thumb, +.rbac-assignments-list::-webkit-scrollbar-thumb { + border-radius: 999px; + background: color-mix(in srgb, var(--text-muted) 52%, transparent); +} + +.rbac-user-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-rows: auto auto auto; + align-items: start; + gap: 3px 10px; + width: 100%; + min-height: 0; + margin-bottom: 4px; + padding: 9px 11px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--text-primary); + text-align: left; + cursor: pointer; +} + +.rbac-user-name, +.rbac-user-handle { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rbac-user-name { + grid-column: 1; + grid-row: 1; + font-size: 0.875rem; + font-weight: 650; + line-height: 1.25; +} + +.rbac-user-row .rbac-pill { + grid-column: 2; + grid-row: 1; + justify-self: end; + align-self: center; + min-height: 20px; + padding: 1px 7px; + font-size: 0.6875rem; +} + +.rbac-user-handle { + grid-column: 1 / -1; + grid-row: 2; + color: var(--text-secondary); + font-size: 0.75rem; + line-height: 1.25; +} + +.rbac-user-summary { + grid-column: 1 / -1; + grid-row: 3; + display: -webkit-box; + overflow: hidden; + color: var(--text-muted); + font-size: 0.75rem; + line-height: 1.35; + white-space: normal; + word-break: break-word; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.rbac-assignment-toolbar { + position: static; + flex: 0 0 auto; + z-index: 1; + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + box-shadow: none; +} + +.rbac-assignment-toolbar-main { + display: grid; + grid-template-columns: minmax(140px, 180px) minmax(220px, 1fr); + align-items: end; + gap: 10px; +} + +.rbac-assignment-field--search { + flex: 1; + min-width: 0; +} + +.rbac-advanced-assignment--inline { + margin: 0; + border: 0; + border-top: 1px dashed color-mix(in srgb, var(--border-color) 88%, transparent); + border-radius: 0; + background: transparent; +} + +.rbac-advanced-assignment--inline summary { + padding: 2px 0 0; + color: var(--text-secondary); + font-size: 0.75rem; + cursor: pointer; + list-style: none; +} + +.rbac-advanced-assignment--inline summary::-webkit-details-marker { + display: none; +} + +.rbac-advanced-assignment--inline summary::before { + content: '▸'; + display: inline-block; + width: 14px; + color: var(--text-muted); + transition: transform 0.15s ease; +} + +.rbac-advanced-assignment--inline[open] summary::before { + transform: rotate(90deg); +} + +.rbac-advanced-assignment-body { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(180px, 280px); + align-items: center; + gap: 10px; + padding-top: 8px; +} + +.rbac-advanced-assignment-body span { + color: var(--text-secondary); + font-size: 0.6875rem; + line-height: 1.35; +} + +.rbac-assignment-controls { + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: 8px; +} + +.rbac-advanced-assignment { + flex: 0 0 auto; + margin-bottom: 0; +} + +.rbac-assignment-help { + flex: 0 0 auto; + margin: 0; + color: var(--text-secondary); + font-size: 0.71875rem; + line-height: 1.35; +} + +.rbac-assignment-columns { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: stretch; + gap: 12px; + flex: 1 1 auto; + min-height: clamp(320px, 46vh, 680px); +} + +.rbac-resource-section { + display: grid; + grid-template-rows: auto minmax(clamp(220px, 34vh, 560px), 1fr) auto; + min-width: 0; + min-height: 0; + height: 100%; + overflow: hidden; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-primary); +} + +.rbac-resource-section-head { + display: flex; + flex-direction: column; + justify-content: center; + gap: 8px; + min-height: auto; + padding: 10px 12px; + border-bottom: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bg-secondary) 70%, var(--bg-primary)); +} + +.rbac-resource-section-head > div:first-child { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} + +.rbac-resource-section-head strong { + color: var(--text-primary); + font-size: 0.8125rem; +} + +.rbac-resource-section-head span { + color: var(--text-secondary); + font-size: 0.71875rem; +} + +.rbac-assignment-filters { + display: grid; + grid-template-columns: minmax(112px, 0.7fr) minmax(150px, 1.3fr); + gap: 8px; + margin-top: 0; +} + +.rbac-assignment-filters :is(.form-select, .form-input) { + height: 40px; + min-height: 40px; +} + +.rbac-assignment-filters .settings-custom-select-trigger { + height: 40px; + min-height: 40px; + padding: 8px 11px; + border-radius: 8px; + font-size: 0.8125rem; +} + +.platform-rbac-page .settings-custom-select, +#rbac-user-modal .settings-custom-select, +#rbac-role-modal .settings-custom-select { + width: 100%; + min-width: 0; +} + +.platform-rbac-page .settings-custom-select-menu, +#rbac-user-modal .settings-custom-select-menu, +#rbac-role-modal .settings-custom-select-menu { + z-index: 3200; +} + +.platform-rbac-page .settings-custom-select-trigger, +#rbac-user-modal .settings-custom-select-trigger, +#rbac-role-modal .settings-custom-select-trigger, +.rbac-assignment-field .settings-custom-select-trigger, +.rbac-assignment-filters .settings-custom-select-trigger { + min-height: 40px; + height: 40px; + padding: 8px 11px; + border-radius: 8px; + font-size: 0.8125rem; + background: var(--bg-primary); + border-color: var(--border-color); + color: var(--text-primary); +} + +.platform-rbac-page .settings-custom-select-trigger:hover:not(:disabled), +#rbac-user-modal .settings-custom-select-trigger:hover:not(:disabled), +#rbac-role-modal .settings-custom-select-trigger:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--accent-color) 38%, var(--border-color)); +} + +.platform-rbac-page .settings-custom-select.open .settings-custom-select-trigger, +#rbac-user-modal .settings-custom-select.open .settings-custom-select-trigger, +#rbac-role-modal .settings-custom-select.open .settings-custom-select-trigger { + border-color: var(--accent-color); + box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.12); +} + +.platform-rbac-page .settings-custom-select-option.is-selected, +#rbac-user-modal .settings-custom-select-option.is-selected, +#rbac-role-modal .settings-custom-select-option.is-selected { + color: var(--accent-color); + background: rgba(0, 102, 255, 0.08); +} + +.rbac-assignment-filters--placeholder { + display: none; +} + +.rbac-resource-picker { + display: flex; + flex-direction: column; + gap: 6px; + min-height: clamp(220px, 34vh, 560px); + margin: 0; + padding: 8px; + overflow-y: auto; + overscroll-behavior: contain; +} + +.rbac-resource-option { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-height: 44px; + padding: 7px 10px; +} + +.rbac-resource-option-main { + min-width: 0; +} + +.rbac-resource-option-main strong, +.rbac-resource-option-main small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rbac-resource-option-detail { + max-width: 120px; + text-align: right; + color: var(--text-secondary); + font-size: 0.75rem; +} + +.rbac-no-role-notice { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 0 0 12px; + padding: 10px 12px; + border: 1px solid rgba(234, 88, 12, 0.28); + border-radius: 8px; + background: rgba(234, 88, 12, 0.08); +} + +.rbac-no-role-notice[hidden] { + display: none !important; +} + +.rbac-no-role-notice-copy { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.rbac-no-role-notice-copy strong { + color: #9a3412; + font-size: 0.8125rem; +} + +.rbac-no-role-notice-copy span { + color: var(--text-secondary); + font-size: 0.75rem; + line-height: 1.45; +} + +@keyframes rbac-focus-pulse { + 0%, 100% { + box-shadow: none; + } + + 50% { + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.35); + } +} + +.rbac-role-grid.rbac-focus-pulse, +#rbac-tab-roles.rbac-focus-pulse { + animation: rbac-focus-pulse 1.2s ease; + border-radius: 8px; +} + +.rbac-pending-bar { + display: flex; + flex-direction: column; + gap: 10px; + margin: 0 0 12px; + padding: 12px; + border: 1px solid rgba(37, 99, 235, 0.24); + border-radius: 8px; + background: rgba(37, 99, 235, 0.05); +} + +.rbac-pending-bar[hidden] { + display: none !important; +} + +.rbac-pending-bar-head { + display: flex; + align-items: center; + gap: 8px; +} + +.rbac-pending-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.rbac-pending-item { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 6px 10px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: var(--bg-primary); +} + +.rbac-pending-item-main { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.rbac-pending-item-main strong, +.rbac-pending-item-main small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rbac-pending-item-main small { + color: var(--text-muted); + font-size: 0.6875rem; +} + +.rbac-pending-bar-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.rbac-resource-selection-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 8px; + padding: 8px 10px; + border: 1px solid rgba(37, 99, 235, 0.22); + border-radius: 8px; + background: rgba(37, 99, 235, 0.06); +} + +.rbac-resource-selection-actions { + display: flex; + gap: 8px; +} + +.rbac-assignment-resource-main { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.rbac-assignment-resource-main strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rbac-assignment-resource-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + color: var(--text-secondary); + font-size: 0.75rem; +} + +.rbac-assignment-resource-meta code { + font-size: 0.6875rem; +} + +.rbac-resource-option-main small { + display: flex; + align-items: center; + gap: 6px; +} + +.rbac-assignments-list { + display: flex; + flex-direction: column; + gap: 6px; + min-height: clamp(220px, 34vh, 560px); + padding: 8px; + overflow-y: auto; + overscroll-behavior: contain; +} + +.rbac-assignments-list .rbac-empty, +.rbac-assignments-list .empty-state, +.rbac-resource-picker .rbac-picker-status, +.rbac-resource-picker .rbac-empty { + flex: 1 1 auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 120px; + margin: 0; + padding: 20px 16px; + border: 1px dashed var(--border-color); + border-radius: 8px; + background: color-mix(in srgb, var(--bg-secondary) 55%, var(--bg-primary)); +} + +.rbac-assignment-row { + min-height: 45px; + padding: 8px 10px; +} + +.rbac-pagination { + display: grid; + grid-template-columns: max-content minmax(0, 1fr) max-content; + align-items: center; + gap: 8px; + flex: 0 0 auto; + min-height: 48px; + padding: 8px; + border-top: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bg-secondary) 68%, var(--bg-primary)); +} + +.rbac-pagination[hidden] { + display: none !important; +} + +.rbac-pagination > span { + overflow: hidden; + color: var(--text-secondary); + font-size: 0.71875rem; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rbac-pagination .btn-small { + min-height: 30px; + padding: 4px 9px; +} + +@container rbac-page (max-width: 980px) { + .rbac-assignment-columns { + grid-template-columns: minmax(0, 1fr); + } +} + +@container rbac-page (max-width: 760px) { + .platform-rbac-page > .page-content { + overflow-y: auto; + } + + .rbac-layout { + height: auto; + min-height: min(720px, 100%); + overflow: visible; + } + + .rbac-panel { + height: auto; + } + + .rbac-panel-users { + height: auto; + max-height: min(420px, 60vh); + } + + .rbac-assignment-columns { + min-height: clamp(360px, 52vh, 720px); + } + + .rbac-panel-detail #rbac-tab-assignments.rbac-tab-panel:not([hidden]) { + overflow-y: visible; + } +} + +@media (max-height: 860px) { + .rbac-assignment-help { + display: none; + } + + .rbac-panel-detail .rbac-selected-summary > div { + padding: 7px 10px; + } +} + +@container rbac-page (max-width: 560px) { + .rbac-assignment-filters { + grid-template-columns: minmax(0, 1fr); + align-items: start; + } + + .rbac-resource-section-head > div:first-child { + align-items: flex-start; + flex-direction: column; + } +} + .mcp-stats-combined__col-label { margin: 0; font-size: 0.6875rem; @@ -11524,6 +12289,26 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { line-height: 1.25; } +.rbac-assignment-toolbar-main :is( + input.form-input, + .settings-custom-select-trigger, + .btn-primary.btn-small +) { + height: 40px; + min-height: 40px; + box-sizing: border-box; + border-radius: 8px; +} + +.rbac-assignment-toolbar-main .settings-custom-select-trigger { + padding: 8px 11px; +} + +.rbac-assignment-toolbar-main .btn-primary.btn-small { + padding: 0 14px; + line-height: 1.25; +} + .btn-small:hover, .btn-sm:hover { background: var(--bg-tertiary); @@ -18226,6 +19011,150 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { display: none; } } + +/* RBAC workspace: task-oriented member and role management */ +.rbac-workspace-nav { + display: flex; + gap: 4px; + width: fit-content; + margin-bottom: 12px; + padding: 4px; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-secondary); +} + +.rbac-workspace-tab { + min-width: 210px; + padding: 9px 14px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--text-secondary); + text-align: left; + cursor: pointer; +} + +.rbac-workspace-tab span, +.rbac-workspace-tab small { display: block; } +.rbac-workspace-tab span { font-size: 0.875rem; font-weight: 650; } +.rbac-workspace-tab small { margin-top: 2px; color: var(--text-muted); font-size: 0.71875rem; } +.rbac-workspace-tab:hover { color: var(--text-primary); } +.rbac-workspace-tab.is-active { + color: var(--primary-color, #2563eb); + background: var(--bg-primary); + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); +} + +.rbac-workspace-view[hidden] { display: none !important; } +#rbac-role-save-btn[hidden] { display: none !important; } +.rbac-user-row.is-active { box-shadow: inset 3px 0 0 var(--primary-color, #2563eb); } +.rbac-role-card.is-selected { + border-color: rgba(37, 99, 235, 0.4); + background: rgba(37, 99, 235, 0.035); + box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.08); +} + +.rbac-pill.is-info { color: #1d4ed8; border-color: rgba(37, 99, 235, 0.24); background: rgba(37, 99, 235, 0.08); } +.rbac-pill.is-warn { color: #9a3412; border-color: rgba(234, 88, 12, 0.24); background: rgba(234, 88, 12, 0.08); } +.rbac-pill.is-custom { color: #6d28d9; border-color: rgba(109, 40, 217, 0.2); background: rgba(109, 40, 217, 0.07); } + +.rbac-assignment-help, +.rbac-field-hint { + margin: 0 0 12px; + color: var(--text-secondary); + font-size: 0.75rem; + line-height: 1.5; +} +.rbac-field-hint { margin: 6px 0 0; } + +.rbac-role-management { + min-height: 500px; + padding: 16px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); +} + +.rbac-role-management-head, +.rbac-role-management-actions, +.rbac-catalog-card-head, +.rbac-catalog-card-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.rbac-role-management-head { padding-bottom: 16px; border-bottom: 1px solid var(--border-color); } +.rbac-role-management-head h3, +.rbac-catalog-card h4 { margin: 0; } +.rbac-role-management-actions .form-input { width: min(280px, 32vw); min-height: 36px; } +.rbac-role-catalog { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 12px; + padding-top: 16px; +} + +.rbac-catalog-card { + min-width: 0; + padding: 15px; + border: 1px solid var(--border-color); + border-radius: 9px; + background: var(--bg-primary); +} +.rbac-catalog-card-head { align-items: flex-start; } +.rbac-catalog-card-head p { margin: 5px 0 0; color: var(--text-secondary); font-size: 0.8125rem; line-height: 1.45; } +.rbac-catalog-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 14px 0 12px; } +.rbac-catalog-stats div { min-width: 0; padding: 9px; border-radius: 7px; background: var(--bg-secondary); } +.rbac-catalog-stats strong, +.rbac-catalog-stats span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rbac-catalog-stats strong { color: var(--text-primary); font-size: 0.9375rem; } +.rbac-catalog-stats span { margin-top: 3px; color: var(--text-secondary); font-size: 0.6875rem; } +.rbac-module-tags { display: flex; flex-wrap: wrap; gap: 6px; min-height: 24px; } +.rbac-module-tags span { + padding: 3px 7px; + border-radius: 5px; + color: var(--text-secondary); + background: var(--bg-secondary); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.6875rem; +} +.rbac-catalog-card-foot { align-items: flex-end; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border-color); } +.rbac-catalog-card-foot > span { color: var(--text-secondary); font-size: 0.75rem; } +.rbac-catalog-card-foot > div { display: flex; flex-shrink: 0; gap: 6px; } +.rbac-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + min-height: 160px; + padding: 24px; + color: var(--text-secondary); + text-align: center; +} +.rbac-empty strong { color: var(--text-primary); font-size: 0.875rem; } +.rbac-empty span { font-size: 0.75rem; } +.rbac-role-option-text, +.rbac-permission-option { min-width: 0; } +.rbac-role-option-text strong, +.rbac-role-option-text small, +.rbac-permission-option strong, +.rbac-permission-option code { display: block; } +.rbac-role-option-text small, +.rbac-permission-option code { margin-top: 3px; color: var(--text-secondary); font-size: 0.6875rem; } +.rbac-permission-option strong { font-size: 0.75rem; font-weight: 550; line-height: 1.35; } +.rbac-permission-option code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +@media (max-width: 980px) { + .rbac-workspace-nav { width: 100%; } + .rbac-workspace-tab { flex: 1; min-width: 0; } + .rbac-role-management-head { align-items: flex-start; flex-direction: column; } + .rbac-role-management-actions, + .rbac-role-management-actions .form-input { width: 100%; } +} .webshell-ai-hint { flex-shrink: 0; padding: 10px 14px; @@ -34868,3 +35797,1423 @@ html[data-theme="dark"] .form-group select { grid-column: 1 / -1; } } + +/* Platform RBAC */ +.platform-rbac-page .page-content { + overflow: hidden; +} + +.platform-rbac-page .page-header { align-items: center; } +.rbac-page-subtitle { margin: 5px 0 0; color: var(--text-secondary); font-size: 0.8125rem; } + +.rbac-overview { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.rbac-metric { + min-width: 0; + padding: 12px 14px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); +} + +.rbac-metric span { + display: block; + color: var(--text-secondary); + font-size: 0.75rem; + line-height: 1.2; +} + +.rbac-metric strong { + display: block; + margin-top: 6px; + color: var(--text-primary); + font-size: 1.375rem; + line-height: 1; +} + +.rbac-layout { + display: grid; + grid-template-columns: minmax(280px, 328px) minmax(0, 1fr); + gap: 16px; + align-items: stretch; + height: 100%; + min-height: 0; + overflow: hidden; +} + +.rbac-panel { + min-width: 0; + min-height: 0; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + display: flex; + flex-direction: column; +} + +.rbac-panel-head, +.rbac-detail-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid var(--border-color); +} + +.rbac-panel-head h3, +.rbac-detail-head h3 { + margin: 0; + font-size: 1rem; +} + +.rbac-muted { + margin: 4px 0 0; + color: var(--text-secondary); + font-size: 0.8125rem; +} + +.rbac-count, +.rbac-pill { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 2px 8px; + border: 1px solid var(--border-color); + border-radius: 999px; + color: var(--text-secondary); + font-size: 0.75rem; + line-height: 1.2; + white-space: nowrap; +} + +.rbac-pill.is-ok { + color: #166534; + border-color: rgba(22, 101, 52, 0.24); + background: rgba(22, 101, 52, 0.08); +} + +.rbac-pill.is-muted { + color: var(--text-muted); +} + +.rbac-list, +.rbac-tab-panel { + min-height: 0; + overflow: auto; + padding: 12px; +} + +.rbac-search { + padding: 12px 14px 0; +} + +.rbac-search .form-input { + width: 100%; + min-height: 36px; + border-radius: 8px; +} + +.rbac-user-row { + width: 100%; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--text-primary); + text-align: left; + cursor: pointer; +} + +.rbac-user-row:hover, +.rbac-user-row.is-active { + border-color: rgba(37, 99, 235, 0.28); + background: rgba(37, 99, 235, 0.05); +} + +.rbac-detail-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.rbac-selected-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + padding: 12px 16px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-secondary); +} + +.rbac-selected-summary:empty { + display: none; +} + +.rbac-selected-summary div { + min-width: 0; +} + +.rbac-selected-summary span { + display: block; + color: var(--text-secondary); + font-size: 0.71875rem; +} + +.rbac-selected-summary strong { + display: block; + margin-top: 3px; + color: var(--text-primary); + font-size: 0.9375rem; +} + +.rbac-tabs { + display: flex; + gap: 6px; + padding: 10px 12px 0; +} + +.rbac-tab { + border: 1px solid var(--border-color); + border-radius: 8px 8px 0 0; + background: var(--bg-secondary); + color: var(--text-secondary); + padding: 8px 12px; + cursor: pointer; +} + +.rbac-tab.is-active { + background: var(--bg-primary); + color: var(--text-primary); + border-bottom-color: var(--bg-primary); +} + +.rbac-section-toolbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.rbac-section-toolbar strong { + display: block; + font-size: 0.9375rem; +} + +.rbac-role-save-actions { + display: flex; + align-items: center; + gap: 7px; +} + +.rbac-unsaved-indicator { + color: #9a3412; + font-size: 0.75rem; +} + +.rbac-unsaved-indicator[hidden] { display: none !important; } + +.rbac-role-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; +} + +.rbac-role-card { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 12px; + background: var(--bg-primary); + transition: border-color 0.15s, box-shadow 0.15s; +} + +.rbac-role-card:hover { + border-color: rgba(37, 99, 235, 0.26); + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.06); +} + +.rbac-role-card-head, +.rbac-role-card-foot, +.rbac-assignment-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.rbac-role-card p { + min-height: 36px; + margin: 10px 0; + color: var(--text-secondary); + font-size: 0.8125rem; + line-height: 1.45; +} + +.rbac-role-card-foot { + color: var(--text-secondary); + font-size: 0.8125rem; + justify-content: flex-start; + flex-wrap: wrap; +} + +.rbac-assignment-toolbar { + margin-bottom: 0; +} + +.rbac-assignment-field { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 160px; +} + +.rbac-assignment-field label { + color: var(--text-secondary); + font-size: 0.75rem; + font-weight: 600; +} + +.rbac-assignment-field--id { + flex: 1; + min-width: 280px; +} + +.rbac-assignment-toolbar .btn-primary { + align-self: end; +} + +.rbac-assignments-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.rbac-resource-picker { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 8px; + max-height: 260px; + margin-bottom: 12px; + overflow: auto; +} + +.rbac-resource-option { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + cursor: pointer; +} + +.rbac-resource-option:hover { + border-color: rgba(37, 99, 235, 0.32); + background: rgba(37, 99, 235, 0.035); +} + +.rbac-resource-option:has(input:checked) { + border-color: rgba(37, 99, 235, 0.45); + background: rgba(37, 99, 235, 0.06); +} + +.rbac-resource-option.is-assigned { + cursor: default; + opacity: 0.7; +} + +.rbac-resource-option-main { + flex: 1; + min-width: 0; +} + +.rbac-resource-option-main strong, +.rbac-resource-option-main small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rbac-resource-option-main strong { + color: var(--text-primary); + font-size: 0.8125rem; +} + +.rbac-resource-option-main small, +.rbac-resource-option-detail { + margin-top: 3px; + color: var(--text-secondary); + font-size: 0.6875rem; +} + +.rbac-resource-option-detail { + flex-shrink: 0; + max-width: 90px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rbac-picker-status { + grid-column: 1 / -1; + padding: 28px 16px; + border: 1px dashed var(--border-color); + border-radius: 8px; + color: var(--text-secondary); + font-size: 0.8125rem; + text-align: center; +} + +.rbac-picker-status.is-error { color: var(--danger-color, #dc2626); } +.rbac-button-count { + min-width: 18px; + margin-left: 5px; + padding: 1px 5px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.2); + font-size: 0.6875rem; +} + +.rbac-advanced-assignment { + margin-bottom: 14px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); +} + +.rbac-advanced-assignment summary { + padding: 9px 11px; + color: var(--text-secondary); + font-size: 0.75rem; + cursor: pointer; +} + +.rbac-advanced-assignment > div { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 0 11px 11px; +} + +.rbac-advanced-assignment span { + max-width: 280px; + color: var(--text-secondary); + font-size: 0.6875rem; +} + +.rbac-assignment-list-head { + display: flex; + align-items: center; + justify-content: space-between; + margin: 4px 0 9px; + color: var(--text-primary); + font-size: 0.8125rem; +} + +.rbac-assignment-list-head span { + color: var(--text-secondary); + font-size: 0.75rem; +} + +.rbac-assignment-row { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 10px 12px; + background: var(--bg-primary); +} + +.rbac-resource-selection-bar { + display: none !important; +} + +.rbac-metric-context { + margin: -4px 0 10px; + color: var(--text-secondary); + font-size: 0.75rem; +} + +.rbac-metric-enabled[hidden] { + display: none !important; +} + +.rbac-summary-permissions { + position: relative; +} + +.rbac-summary-toggle { + margin-left: 4px; + vertical-align: middle; +} + +.rbac-effective-permissions { + margin: 0 16px 12px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); +} + +.rbac-effective-permissions-head, +.rbac-effective-module h4 { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.rbac-effective-permissions-head { + padding: 10px 12px; + border-bottom: 1px solid var(--border-color); +} + +.rbac-effective-permissions-body { + display: flex; + flex-direction: column; + gap: 10px; + max-height: 240px; + padding: 10px 12px 12px; + overflow-y: auto; +} + +.rbac-effective-module h4 { + margin: 0 0 6px; + font-size: 0.8125rem; +} + +.rbac-effective-module h4 span { + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 500; +} + +.rbac-effective-permission-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.rbac-effective-permission-tags span { + padding: 3px 8px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 0.6875rem; +} + +.rbac-audit-list { + display: flex; + flex-direction: column; + gap: 8px; + min-height: 220px; +} + +.rbac-audit-row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + font-size: 0.8125rem; + line-height: 1.4; +} + +.rbac-audit-action { + flex: 0 0 auto; + font-weight: 600; +} + +.rbac-audit-actor { + flex: 0 0 auto; +} + +.rbac-audit-detail { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-secondary); + font-size: 0.75rem; +} + +.rbac-audit-time { + flex: 0 0 auto; + margin-left: auto; + color: var(--text-secondary); + font-size: 0.75rem; + white-space: nowrap; +} + +.rbac-user-roles-readonly { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 8px; +} + +.rbac-readonly-role { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); +} + +.rbac-empty.is-compact { + min-height: auto; + padding: 12px; +} + +.rbac-assignment-resource { + display: flex; + align-items: flex-start; + gap: 10px; + flex: 1; + min-width: 0; +} + +.rbac-assignment-row code { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-primary); +} + +.rbac-checkbox-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 8px; +} + +.rbac-checkbox-item { + padding: 8px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); +} + +.rbac-permission-groups { + display: flex; + flex-direction: column; + gap: 14px; + max-height: 48vh; + overflow: auto; + padding-right: 4px; +} + +.rbac-permission-tools { + display: grid; + grid-template-columns: minmax(220px, 1fr) auto auto; + gap: 8px; + margin-bottom: 12px; +} + +.rbac-permission-tools .form-input { + min-height: 36px; +} + +.rbac-permission-group { + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); +} + +.rbac-permission-group-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; +} + +.rbac-permission-group-head h4 { + margin: 0; + font-size: 0.875rem; +} + +.rbac-permission-group-head h4 small { + margin-left: 8px; + color: var(--text-muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.6875rem; + font-weight: 500; +} + +.rbac-permission-group-head span { + color: var(--text-secondary); + font-size: 0.75rem; +} + +@media (max-width: 980px) { + .rbac-overview { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .rbac-layout { + grid-template-columns: minmax(0, 1fr); + height: auto; + } + + .rbac-selected-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .rbac-permission-tools { + grid-template-columns: minmax(0, 1fr); + } +} + +/* RBAC adaptive polish: one scroll surface, predictable controls and dense admin layouts. */ +.platform-rbac-page, +.platform-rbac-page *, +#rbac-user-modal, +#rbac-user-modal *, +#rbac-role-modal, +#rbac-role-modal * { + box-sizing: border-box; +} + +.platform-rbac-page > .page-content { + container-name: rbac-page; + container-type: inline-size; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + padding: 16px 20px 24px; +} + +.platform-rbac-page > .page-header { + position: relative; + z-index: 2; + gap: 16px; + padding: 14px 20px; +} + +.platform-rbac-page > .page-header > div:first-child { + min-width: 0; +} + +.platform-rbac-page .page-header-actions { + flex-wrap: wrap; + justify-content: flex-end; +} + +.platform-rbac-page :is(input.form-input, select.form-select), +#rbac-user-modal .form-input, +#rbac-role-modal :is(.form-input, .form-select) { + width: 100%; + min-width: 0; + height: 40px; + padding: 8px 11px; + border: 1px solid var(--border-color); + border-radius: 8px; + outline: none; + background: var(--bg-primary); + color: var(--text-primary); + font: inherit; + font-size: 0.8125rem; + line-height: 1.25; + transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; +} + +.platform-rbac-page select.form-select, +#rbac-role-modal select.form-select { + padding-right: 32px; +} + +.platform-rbac-page :is(input.form-input, select.form-select):hover, +#rbac-user-modal .form-input:hover, +#rbac-role-modal :is(.form-input, .form-select):hover { + border-color: color-mix(in srgb, var(--accent-color) 38%, var(--border-color)); +} + +.platform-rbac-page :is(input.form-input, select.form-select):focus, +#rbac-user-modal .form-input:focus, +#rbac-role-modal :is(.form-input, .form-select):focus { + border-color: var(--accent-color); + box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.12); +} + +.platform-rbac-page input.form-input::placeholder, +#rbac-user-modal .form-input::placeholder, +#rbac-role-modal .form-input::placeholder { + color: var(--text-muted); + opacity: 1; +} + +.platform-rbac-page input[type="search"]::-webkit-search-cancel-button { + cursor: pointer; +} + +.platform-rbac-page :is(.btn-primary, .btn-secondary):disabled, +#rbac-user-modal :is(.btn-primary, .btn-secondary):disabled, +#rbac-role-modal :is(.btn-primary, .btn-secondary):disabled { + transform: none; + box-shadow: none; + cursor: not-allowed; + opacity: 0.48; +} + +.rbac-topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-bottom: 16px; +} + +.rbac-overview { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + flex: 1 1 auto; + margin-bottom: 0; +} + +.rbac-metric { + --rbac-metric-accent: var(--primary-color, #2563eb); + --rbac-metric-bg: rgba(37, 99, 235, 0.09); + --rbac-metric-border: rgba(37, 99, 235, 0.34); + display: inline-flex; + align-items: baseline; + gap: 5px; + width: fit-content; + min-width: 0; + padding: 7px 11px; + border-color: var(--rbac-metric-border); + border-radius: 999px; + background: var(--rbac-metric-bg); +} + +.rbac-metric strong { + order: -1; + margin-top: 0; + color: var(--rbac-metric-accent); + font-size: 0.9375rem; + font-weight: 800; + line-height: 1; +} + +.rbac-metric span { + color: var(--rbac-metric-accent); + font-size: 0.8125rem; + font-weight: 700; + line-height: 1; + white-space: nowrap; +} + +.rbac-metric-users, +.rbac-metric-enabled { + --rbac-metric-accent: #2563eb; + --rbac-metric-bg: rgba(37, 99, 235, 0.09); + --rbac-metric-border: rgba(37, 99, 235, 0.34); +} + +.rbac-metric-roles { + --rbac-metric-accent: #7c3aed; + --rbac-metric-bg: rgba(124, 58, 237, 0.1); + --rbac-metric-border: rgba(124, 58, 237, 0.34); +} + +.rbac-metric-assignments { + --rbac-metric-accent: #ea580c; + --rbac-metric-bg: rgba(234, 88, 12, 0.1); + --rbac-metric-border: rgba(234, 88, 12, 0.34); +} + +.rbac-workspace-nav { + flex: 0 0 auto; + width: auto; + margin-bottom: 0; + align-self: center; + border-radius: 999px; +} + +.rbac-workspace-tab { + flex: 0 0 auto; + min-width: 0; + padding: 7px 12px; + border-radius: 999px; + text-align: center; + white-space: nowrap; +} + +.rbac-workspace-tab span { + font-size: 0.8125rem; +} + +.rbac-workspace-tab small { + display: none; +} + +.rbac-layout { + grid-template-columns: minmax(280px, 328px) minmax(0, 1fr); + align-items: stretch; + height: 100%; + min-height: 0; + overflow: hidden; +} + +.rbac-panel { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + border-radius: 10px; +} + +.rbac-panel-users { + position: static; + height: auto; + max-height: none; +} + +.rbac-panel-users .rbac-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding: 4px 8px 12px; +} + +.rbac-panel-detail { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +.rbac-panel-detail .rbac-detail-head, +.rbac-panel-detail .rbac-selected-summary, +.rbac-panel-detail .rbac-tabs { + flex: 0 0 auto; +} + +.rbac-panel-detail .rbac-tab-panel { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + padding: 14px; +} + +.rbac-panel-detail #rbac-tab-roles.rbac-tab-panel:not([hidden]) { + overflow-y: auto; + overscroll-behavior: contain; +} + +.rbac-panel-detail #rbac-tab-assignments.rbac-tab-panel:not([hidden]) { + display: flex; + flex-direction: column; + gap: 8px; + overflow-y: auto; + overscroll-behavior: contain; +} + +.rbac-panel-detail .rbac-selected-summary { + padding: 0; +} + +.rbac-panel-detail .rbac-selected-summary > div { + padding: 9px 12px; +} + +.rbac-panel-detail .rbac-selected-summary span { + font-size: 0.6875rem; +} + +.rbac-panel-detail .rbac-selected-summary strong { + margin-top: 2px; + font-size: 0.875rem; +} + +.rbac-panel-detail .rbac-detail-head { + min-height: 0; + padding: 8px 14px; +} + +.rbac-panel-detail .rbac-detail-head h3 { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 8px; + margin: 0; + font-size: 0.9375rem; + line-height: 1.3; +} + +.rbac-detail-name { + font-weight: 650; +} + +.rbac-detail-meta { + color: var(--text-secondary); + font-size: 0.8125rem; + font-weight: 500; +} + +.rbac-panel-detail .rbac-detail-head .rbac-muted[hidden] { + display: none !important; +} + +.rbac-panel-detail .rbac-tabs { + gap: 12px; + padding: 0 12px; +} + +.rbac-panel-head, +.rbac-detail-head { + min-height: 0; + padding: 8px 14px; +} + +.rbac-detail-head > div:first-child, +.rbac-panel-head > div:first-child { + min-width: 0; +} + +.rbac-detail-actions { + flex: 0 0 auto; + flex-wrap: nowrap; +} + +.rbac-search { + padding: 12px; +} + +.rbac-search .form-input { + min-height: 40px; + border-radius: 8px; +} + +.rbac-user-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-rows: auto auto auto; + align-items: start; + gap: 3px 10px; + min-height: 0; + margin-bottom: 4px; + padding: 9px 11px; +} + +.rbac-user-meta { + width: 100%; + max-width: none; +} + +.rbac-selected-summary { + gap: 0; + padding: 0; +} + +.rbac-selected-summary > div { + padding: 12px 15px; + border-right: 1px solid var(--border-color); +} + +.rbac-selected-summary > div:last-child { + border-right: 0; +} + +.rbac-tabs { + gap: 18px; + padding: 0 15px; + border-bottom: 1px solid var(--border-color); +} + +.rbac-tab { + margin-bottom: -1px; + padding: 11px 1px 10px; + border: 0; + border-bottom: 2px solid transparent; + border-radius: 0; + background: transparent; + font-size: 0.8125rem; +} + +.rbac-tab.is-active { + border-bottom-color: var(--accent-color); + background: transparent; + color: var(--accent-color); + font-weight: 600; +} + +.rbac-section-toolbar { + align-items: center; + flex-wrap: wrap; + row-gap: 10px; + padding-bottom: 2px; +} + +.rbac-section-toolbar > div:first-child { + flex: 1 1 280px; + min-width: 0; +} + +.rbac-role-save-actions { + flex: 0 0 auto; + flex-wrap: wrap; + justify-content: flex-end; +} + +.rbac-role-grid { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + align-items: stretch; + gap: 10px; +} + +.rbac-role-card { + display: grid; + grid-template-rows: auto minmax(38px, auto) auto; + min-width: 0; + min-height: 158px; + padding: 13px; +} + +.rbac-role-card-head { + align-items: flex-start; +} + +.rbac-role-card-head .checkbox-label { + min-width: 0; +} + +.rbac-role-card p { + min-height: 0; + margin: 11px 0; +} + +.rbac-role-card-foot { + align-content: flex-start; + justify-content: flex-start; + gap: 6px 12px; + padding-top: 10px; + border-top: 1px solid var(--border-color); + font-size: 0.75rem; +} + +/* Assignment and permission choices are selections, not on/off switches. */ +:is(.rbac-role-card, .rbac-resource-option, .rbac-checkbox-item) .checkbox-custom { + width: 18px; + height: 18px; + border-width: 1px; + border-radius: 5px; + box-shadow: none; +} + +:is(.rbac-role-card, .rbac-resource-option, .rbac-checkbox-item) .checkbox-custom::before { + top: 2px; + left: 5px; + width: 5px; + height: 9px; + border: solid #fff; + border-width: 0 2px 2px 0; + border-radius: 0; + background: transparent; + box-shadow: none; + opacity: 0; + transform: rotate(45deg); +} + +:is(.rbac-role-card, .rbac-resource-option, .rbac-checkbox-item) .modern-checkbox:checked + .checkbox-custom { + box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.10); +} + +:is(.rbac-role-card, .rbac-resource-option, .rbac-checkbox-item) .modern-checkbox:checked + .checkbox-custom::before { + opacity: 1; + transform: rotate(45deg); +} + +.rbac-assignment-toolbar { + display: flex; + flex-direction: column; + align-items: stretch; + flex-wrap: nowrap; + gap: 8px; + margin-bottom: 0; +} + +.rbac-assignment-toolbar-main { + display: grid; + grid-template-columns: minmax(140px, 180px) minmax(220px, 1fr); + align-items: end; + gap: 10px; +} + +.rbac-assignment-field--search, +.rbac-assignment-field--id { + min-width: 0; +} + +.rbac-assignment-toolbar .btn-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + min-width: 142px; +} + +.platform-rbac-page :is(.rbac-detail-actions, .rbac-role-save-actions, .rbac-assignment-toolbar, .rbac-role-management-actions) .btn-primary { + border-color: var(--accent-color); + background: var(--accent-color); + color: #fff; +} + +.platform-rbac-page :is(.rbac-detail-actions, .rbac-role-save-actions, .rbac-assignment-toolbar, .rbac-role-management-actions) .btn-primary:hover:not(:disabled) { + border-color: var(--accent-hover); + background: var(--accent-hover); +} + +.rbac-assignment-field .settings-custom-select-trigger, +#rbac-role-modal .settings-custom-select-trigger { + min-height: 40px; + height: 40px; +} + +.rbac-assignment-help { + padding: 0 2px; +} + +.rbac-resource-picker { + display: flex; + flex-direction: column; + gap: 6px; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +.rbac-picker-status { + min-height: 100px; + display: flex; + align-items: center; + justify-content: center; +} + +.rbac-advanced-assignment > div { + grid-template-columns: minmax(0, 1fr) minmax(180px, 280px); +} + +.rbac-assignment-list-head { + padding-top: 3px; +} + +.rbac-role-management { + min-height: 0; + padding: 18px; + border-radius: 10px; +} + +.rbac-role-management-head { + align-items: flex-end; + flex-wrap: wrap; + row-gap: 12px; +} + +.rbac-role-management-head > div:first-child { + flex: 1 1 320px; + min-width: 0; +} + +.rbac-role-management-actions { + display: grid; + grid-template-columns: minmax(240px, 340px) max-content; + align-items: end; + flex: 0 1 auto; + gap: 10px; + min-width: 0; +} + +.rbac-toolbar-search { + min-width: 0; +} + +.rbac-toolbar-search label { + display: block; + margin-bottom: 5px; + color: var(--text-secondary); + font-size: 0.75rem; + font-weight: 600; +} + +.rbac-role-management-actions .form-input { + width: 100%; + min-height: 40px; +} + +.rbac-role-management-actions .btn-primary { + min-height: 40px; +} + +.rbac-role-catalog { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); + align-items: stretch; +} + +.rbac-catalog-card { + display: flex; + flex-direction: column; +} + +.rbac-module-tags { + align-content: flex-start; +} + +.rbac-catalog-card-foot { + margin-top: auto; +} + +#rbac-user-modal .modal-content, +#rbac-role-modal .modal-content { + width: calc(100% - 32px); + max-height: calc(100vh - 40px); +} + +#rbac-user-modal .modal-body, +#rbac-role-modal .modal-body { + min-height: 0; + overflow-y: auto; +} + +#rbac-user-modal .rbac-checkbox-grid { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); +} + +#rbac-role-modal .rbac-permission-tools { + grid-template-columns: minmax(220px, 1fr) max-content max-content; + align-items: center; +} + +#rbac-role-modal .rbac-permission-groups { + max-height: min(48vh, 520px); +} + +@container rbac-page (max-width: 1050px) { + .rbac-layout { + grid-template-columns: minmax(250px, 300px) minmax(0, 1fr); + gap: 12px; + } + + .rbac-role-grid { + grid-template-columns: minmax(0, 1fr); + } + + .rbac-assignment-toolbar-main { + grid-template-columns: minmax(130px, 170px) minmax(180px, 1fr); + } + + .rbac-assignment-toolbar-main .btn-primary { + grid-column: 1 / -1; + width: 100%; + } +} + +@container rbac-page (max-width: 760px) { + .rbac-topbar { + flex-direction: column; + } + + .rbac-workspace-nav { + flex-basis: auto; + width: min(100%, 540px); + } + + .rbac-overview { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .rbac-layout { + grid-template-columns: minmax(0, 1fr); + } + + .rbac-panel-users { + position: static; + height: min(420px, 60vh); + max-height: min(420px, 60vh); + } + + .rbac-selected-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .rbac-selected-summary > div:nth-child(2) { + border-right: 0; + } + + .rbac-selected-summary > div:nth-child(-n+2) { + border-bottom: 1px solid var(--border-color); + } + + .rbac-role-management-actions { + width: 100%; + grid-template-columns: minmax(0, 1fr) max-content; + } +} + +@container rbac-page (max-width: 560px) { + .rbac-workspace-nav { + flex-direction: column; + width: 100%; + } + + .rbac-detail-head { + align-items: flex-start; + flex-direction: column; + } + + .rbac-detail-actions, + .rbac-detail-actions > button { + width: 100%; + } + + .rbac-assignment-toolbar-main, + .rbac-role-management-actions { + grid-template-columns: minmax(0, 1fr); + } + + .rbac-assignment-toolbar-main .btn-primary, + .rbac-role-management-actions .btn-primary { + grid-column: auto; + width: 100%; + } + + .rbac-advanced-assignment-body, + #rbac-role-modal .rbac-permission-tools { + grid-template-columns: minmax(0, 1fr); + } + + .rbac-advanced-assignment span { + max-width: none; + } +} + +@media (max-width: 720px) { + #rbac-user-modal .modal-content, + #rbac-role-modal .modal-content { + width: calc(100% - 20px); + max-height: calc(100vh - 20px); + } + + #rbac-role-modal .rbac-permission-tools { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index ddb7b8b2..cf35bafd 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -37,6 +37,15 @@ "language": "Interface language", "backToDashboard": "Back to dashboard", "userMenu": "User menu", + "userMenuFor": "User menu: {{name}}", + "currentUser": "Current user", + "unknownUser": "Unknown user", + "scopeAll": "All resources", + "scopeAssigned": "Assigned resources", + "scopeOwn": "Own resources", + "scopeUnknown": "Unknown scope", + "rolesCount": "{{count}} roles", + "permissionsCount": "{{count}} permissions", "version": "Current version", "toggleSidebar": "Collapse/expand sidebar" }, @@ -59,7 +68,9 @@ "login": { "title": "Sign in to CyberStrikeAI", "titlePrefix": "Sign in to", - "subtitle": "Enter the access password from config", + "subtitle": "Sign in with a platform account. The default administrator is admin.", + "usernameLabel": "Username", + "usernamePlaceholder": "admin", "passwordLabel": "Password", "passwordPlaceholder": "Enter password", "submit": "Sign in" @@ -95,7 +106,8 @@ "c2Tasks": "Tasks", "c2Payloads": "Payload", "c2Events": "Events", - "c2Profiles": "Traffic profiles" + "c2Profiles": "Traffic profiles", + "platformRbac": "Platform Access" }, "dashboard": { "title": "Dashboard", @@ -812,6 +824,7 @@ "running": "Running...", "completed": "Completed", "execFailed": "Execution failed", + "resultMissing": "Result record missing", "toolSucceeded": "Succeeded", "toolFailed": "Failed" }, @@ -1236,6 +1249,7 @@ "robots": "Bots", "terminal": "Terminal", "security": "Security", + "rbac": "Platform permissions", "audit": "Audit logs", "infocollect": "Recon" }, @@ -1404,7 +1418,7 @@ "sessionExpired": "Session expired, please sign in again", "unauthorized": "Unauthorized", "enterPassword": "Please enter password", - "loginFailedCheck": "Sign-in failed, please check the password", + "loginFailedCheck": "Sign-in failed, please check the username or password", "loginFailedRetry": "Sign-in failed, please try again later", "loggedOut": "Signed out" }, @@ -3466,6 +3480,7 @@ "paginationLast": "Last" }, "payloads": { + "386": "386", "title": "Payload Generator", "oneliner": "Oneliner Payload", "build": "Build Beacon", @@ -3485,7 +3500,6 @@ "darwin": "macOS", "amd64": "AMD64", "arm64": "ARM64", - "386": "386", "arm": "ARM", "onelinerDesc": "Generate a one-line reverse shell for the target (Bash / Python / PowerShell / Curl).", "buildDesc": "Cross-compile a full Beacon binary for Linux / Windows / macOS.", @@ -3568,5 +3582,353 @@ "confirmDelete": "Delete this profile?", "submitCreate": "Create" } + }, + "rbac": { + "title": "Platform Access", + "pageSubtitle": "Manage members, roles, and accessible data in one place.", + "createUser": "Add Member", + "memberManagement": "Member Management", + "memberManagementHint": "Assign roles and data scope to members", + "roleManagement": "Role Management", + "roleManagementHint": "Define permission sets and default visibility", + "users": "Users", + "usersHint": "Select an account to manage roles and resource scope.", + "searchUsers": "Search username or display name", + "metricUsers": "Accounts", + "metricEnabled": "Enabled", + "metricRoles": "Roles", + "metricAssignments": "Resource Grants", + "metricUsersPill": "accounts", + "metricEnabledPill": "enabled accounts", + "metricRolesPill": "roles", + "metricAssignmentsPill": "grants", + "selectUser": "Select User", + "selectUserHint": "Select a platform user to edit roles, status, and resource grants.", + "createRole": "New Role", + "tabRoles": "Roles & Permissions", + "tabAssignments": "Resource Grants", + "tabAudit": "Change History", + "roleMembership": "Role Membership", + "roleMembershipHint": "Roles control allowed actions; resource scope controls visible data.", + "unsavedChanges": "Unsaved changes", + "saveRoleChanges": "Save Role Changes", + "resourceType": "Resource Type", + "resourceId": "Resource ID", + "resourceIdPlaceholder": "Enter resource IDs; separate multiple IDs with commas", + "searchResource": "Search Real Resources", + "searchResourcePlaceholder": "Search by name or ID", + "assignSelected": "Grant Selected Resources", + "advancedAssignment": "Advanced: Enter Resource IDs", + "advancedAssignmentHint": "For known IDs copied from logs or APIs. Resource existence is still validated on submit.", + "availableResources": "Available Resources", + "availableResourcesHint": "Select on this page; selections persist across pages", + "currentAssignments": "Current Explicit Grants", + "allResourceTypes": "All Types", + "searchAssignments": "Search granted resource names or IDs", + "items": "items", + "assignmentHelp": "Only Assigned Resources roles need explicit grants. Project grants also cover conversations and vulnerabilities in that project.", + "assignResource": "Grant", + "roleCatalog": "Role Catalog", + "roleCatalogHint": "Define permissions and resource scope first, then assign the role to members.", + "searchRoles": "Search roles or permissions", + "filterRoles": "Filter Roles", + "userModalTitle": "Platform User", + "username": "Username", + "displayName": "Display Name", + "password": "Password", + "passwordPlaceholder": "Leave blank when editing to keep unchanged", + "enabled": "Enable account", + "roles": "Roles", + "roleModalTitle": "Platform Role", + "roleName": "Role Name", + "description": "Description", + "scope": "Resource Scope", + "permissions": "Permissions", + "searchPermissions": "Search permissions, e.g. project:read", + "selectVisible": "Select visible", + "clearVisible": "Clear visible", + "deleteUserConfirm": "Delete this platform user? This will revoke the user's active sessions immediately.", + "deleteBuiltinUser": "The built-in admin user cannot be deleted", + "deleteRoleConfirm": "Delete this platform role?", + "scopeHint": "Resource scope and operation permissions jointly determine effective access.", + "builtinAccount": "Built-in account", + "statusEnabled": "Enabled", + "statusDisabled": "Disabled", + "noRoleAssigned": "No role assigned", + "noExplicitGrant": "No explicit grants", + "notConfigured": "Not configured", + "noRoleDescription": "No role description", + "noDescription": "No description", + "systemBuiltin": "System role", + "customRole": "Custom", + "operationPermissions": "Operation permissions", + "assignedMembers": "Assigned members", + "viewPermissions": "View permissions", + "effectivePermissions": "Effective permissions", + "effectiveScope": "Effective resource scope", + "revoke": "Revoke", + "retry": "Retry", + "alreadyAssigned": "Assigned", + "loadingResources": "Loading real resources…", + "viewSystemRole": "View System Role", + "editRole": "Edit Role", + "resourceCount": "{{count}} resources", + "permissionCount": "{{count}} permissions", + "userCount": "{{count}} users", + "confirmDiscardSwitchUser": "Role changes are not saved. Discard them and switch members?", + "confirmDiscardEditUser": "Role changes are not saved. Discard them and edit the member profile?", + "confirmDiscardSwitchTab": "Role changes are not saved. Discard them and switch tabs?", + "confirmDiscardResourceType": "Switching resource type clears the current selection. Continue?", + "pendingGrants": "Pending Grants", + "clearSelection": "Clear Selection", + "confirmGrant": "Confirm Grant", + "removePending": "Remove", + "selectedCount": "{{count}} selected", + "noRoleNoticeTitle": "No role assigned", + "noRoleNoticeHint": "Assign a role before configuring resource grants. Without a role, only self-created data is accessible and most features are unavailable.", + "assignRolesNow": "Assign Roles", + "viewPermissionsDetail": "View details", + "hidePermissions": "Hide details", + "effectivePermissionsDetail": "Effective permission details", + "editUserRolesHint": "Manage roles on the member detail page under Roles & Permissions.", + "auditTitle": "Permission change history", + "auditHint": "Audit events related to this member's platform access.", + "unknownActor": "Unknown actor", + "metricUserContext": "Selected member: {{name}} · {{roles}} roles · {{grants}} grants", + "deleteUserConfirmDetailed": "Delete member {{name}} (@{{username}})?\n\nThis will revoke active sessions and remove {{roles}} role bindings and {{grants}} resource grants.", + "auditActions": { + "create_user": "Create platform user", + "update_user": "Update platform user", + "delete_user": "Delete platform user", + "create_role": "Create platform role", + "update_role": "Update platform role", + "delete_role": "Delete platform role", + "assign_resource": "Grant resource access", + "delete_resource_assignment": "Revoke resource grant" + }, + "copyId": "Copy ID", + "copiedId": "Copied", + "resourceIdLabel": "ID", + "resourceDetailProject": "Project {{id}}", + "pagination": { + "previous": "Previous", + "next": "Next", + "resourcePage": "Page {{page}}", + "pageSummary": "Page {{page}} / {{pages}} · {{total}} items", + "emptySummary": "0 items" + }, + "resourceTypes": { + "project": "Project", + "conversation": "Conversation", + "vulnerability": "Vulnerability", + "webshell": "WebShell Connection", + "batch_task": "Batch Task", + "c2_listener": "C2 Listener" + }, + "scopes": { + "all": { + "label": "All Resources", + "hint": "Can access all platform data", + "option": "All Resources — Access all platform data" + }, + "assigned": { + "label": "Assigned Resources", + "hint": "Only data explicitly granted by an administrator", + "option": "Assigned Resources — Only administrator-granted data" + }, + "own": { + "label": "Created by Me", + "hint": "Only data created by the member", + "option": "Created by Me — Only self-created data" + } + }, + "systemRoles": { + "admin": { "name": "Administrator", "description": "Full platform administration access" }, + "operator": { "name": "Operator", "description": "Run daily security workflows without account or core configuration management" }, + "auditor": { "name": "Auditor", "description": "Read-only access to audits, monitoring, and assets" }, + "viewer": { "name": "Read-only User", "description": "Read-only access to explicitly granted resources" } + }, + "empty": { + "noMatchingUsers": "No matching members", + "noUsers": "No platform members yet", + "tryAnotherKeyword": "Try another keyword", + "addFirstUser": "Use “Add Member” to create the first member", + "noMatchingRoles": "No matching roles", + "adjustRoleSearch": "Adjust the search or create a custom role", + "noRoles": "No roles available", + "selectUserFirst": "Select a member first", + "noAssignments": "No resource grants", + "addAssignmentHint": "Select resources on the left, then use Confirm Grant above", + "noMatchingAssignments": "No matching grants", + "adjustAssignmentFilter": "Adjust the resource type or search", + "noMatchingResources": "No matching real resources", + "noMatchingPermissions": "No matching permissions", + "noEffectivePermissions": "No effective permissions", + "assignRoleFirst": "Assign a role to view permission details", + "noAuditLogs": "No change history", + "noAuditLogsHint": "Role and grant changes for this member will appear here" + }, + "messages": { + "roleChangesSaved": "Role changes saved and effective immediately", + "grantPartial": "Added {{created}} grants; {{skipped}} already existed or were duplicated", + "grantSuccess": "Granted {{count}} {{resource}} resources" + }, + "errors": { + "loadFailed": "Load failed", + "loadDataFailed": "Unable to load access-control data", + "loadResourcesFailed": "Unable to load resources", + "saveFailed": "Save failed", + "saveUserFailed": "Unable to save member", + "saveRoleFailed": "Unable to save role", + "deleteFailed": "Delete failed", + "deleteUserFailed": "Unable to delete member", + "deleteRoleFailed": "Unable to delete role", + "enterResourceId": "Enter at least one resource ID", + "tooManyResources": "A maximum of 100 resources can be granted at once", + "batchGrantFailed": "Batch grant failed", + "grantFailed": "Grant failed", + "revokeFailed": "Revoke failed", + "loadAuditFailed": "Unable to load audit history" + }, + "permissionModules": { + "agent": "Agent Execution", + "agents": "Agent Definitions", + "attackchain": "Attack Chains", + "audit": "Audit", + "auth": "Account Security", + "c2": "C2", + "chat": "Conversations", + "config": "System Configuration", + "dashboard": "Dashboard", + "files": "Files", + "fofa": "FOFA", + "group": "Conversation Groups", + "hitl": "Human-in-the-loop", + "knowledge": "Knowledge Base", + "mcp": "MCP", + "monitor": "Execution Monitor", + "notification": "Notifications", + "openapi": "OpenAPI", + "project": "Projects", + "rbac": "Platform Access", + "robot": "Robots", + "roles": "Testing Roles", + "skills": "Skills", + "tasks": "Tasks", + "terminal": "Terminal", + "vulnerability": "Vulnerabilities", + "webshell": "WebShell", + "workflow": "Workflows" + }, + "permissionDescriptions": { + "auth": { "self": "Manage own session and password" }, + "dashboard": { "read": "View dashboard summaries" }, + "chat": { + "read": "View conversations", + "write": "Create and update conversations", + "delete": "Delete conversations and turns" + }, + "agent": { "execute": "Run AI agents and workflows" }, + "hitl": { + "read": "View human-in-the-loop queues and logs", + "write": "Approve, dismiss, and configure human-in-the-loop requests" + }, + "tasks": { + "read": "View task queues", + "write": "Create and run task queues", + "delete": "Delete task queues" + }, + "project": { + "read": "View projects and project facts", + "write": "Create and update projects and project facts", + "delete": "Delete projects and project facts" + }, + "vulnerability": { + "read": "View vulnerabilities", + "write": "Create and update vulnerabilities", + "delete": "Delete vulnerabilities" + }, + "webshell": { + "read": "View WebShell connections", + "write": "Manage and use WebShell connections", + "delete": "Delete WebShell connections" + }, + "c2": { + "read": "View C2 listeners, sessions, tasks, events, and profiles", + "write": "Operate C2 listeners, sessions, tasks, payloads, files, and profiles", + "delete": "Delete C2 objects" + }, + "mcp": { + "read": "View MCP status and external MCP configuration", + "write": "Manage external MCP servers and invoke the MCP endpoint" + }, + "knowledge": { + "read": "View the knowledge base and retrieval logs", + "write": "Create, update, index, and scan the knowledge base", + "delete": "Delete knowledge items and retrieval logs" + }, + "skills": { + "read": "View Skills and statistics", + "write": "Create and update Skills", + "delete": "Delete Skills and statistics" + }, + "agents": { + "read": "View Markdown agents", + "write": "Create and update Markdown agents", + "delete": "Delete Markdown agents" + }, + "roles": { + "read": "View AI testing roles", + "write": "Create and update AI testing roles", + "delete": "Delete AI testing roles" + }, + "workflow": { + "read": "View workflow definitions and runs", + "write": "Create, update, validate, run, and resume workflows", + "delete": "Delete workflows" + }, + "config": { + "read": "View system configuration", + "write": "Update and apply system configuration" + }, + "terminal": { "execute": "Execute terminal commands" }, + "audit": { + "read": "View and export audit logs", + "delete": "Delete audit logs" + }, + "rbac": { + "read": "View users, platform roles, permissions, and grants", + "write": "Manage users, platform roles, permissions, and grants" + }, + "notification": { + "read": "View notifications", + "write": "Mark notifications as read" + }, + "robot": { + "read": "View robot binding status", + "write": "Manage robot bindings and test callbacks" + }, + "files": { + "read": "View chat uploads", + "write": "Upload, edit, and rename chat files", + "delete": "Delete chat files" + }, + "attackchain": { + "read": "View attack chains", + "write": "Regenerate attack chains" + }, + "fofa": { "execute": "Run FOFA searches and parse queries" }, + "openapi": { "read": "Read OpenAPI aggregation results" }, + "group": { + "read": "View conversation groups", + "write": "Create and update conversation groups", + "delete": "Delete conversation groups" + }, + "monitor": { + "read": "View the execution monitor", + "write": "Cancel monitored executions", + "delete": "Delete execution-monitor records" + } + } } } diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 91abe59e..2d3ca777 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -37,6 +37,15 @@ "language": "界面语言", "backToDashboard": "返回仪表盘", "userMenu": "用户菜单", + "userMenuFor": "用户菜单:{{name}}", + "currentUser": "当前用户", + "unknownUser": "未知用户", + "scopeAll": "全部资源", + "scopeAssigned": "指定资源", + "scopeOwn": "自己的资源", + "scopeUnknown": "资源范围未知", + "rolesCount": "{{count}} 个角色", + "permissionsCount": "{{count}} 项权限", "version": "当前版本", "toggleSidebar": "折叠/展开侧边栏" }, @@ -59,7 +68,9 @@ "login": { "title": "登录 CyberStrikeAI", "titlePrefix": "登录", - "subtitle": "请输入配置中的访问密码", + "subtitle": "使用平台账号登录,默认管理员为 admin。", + "usernameLabel": "用户名", + "usernamePlaceholder": "admin", "passwordLabel": "密码", "passwordPlaceholder": "输入登录密码", "submit": "登录" @@ -95,7 +106,8 @@ "c2Tasks": "任务", "c2Payloads": "载荷", "c2Events": "事件", - "c2Profiles": "流量伪装" + "c2Profiles": "流量伪装", + "platformRbac": "平台权限" }, "dashboard": { "title": "仪表盘", @@ -800,6 +812,7 @@ "running": "执行中...", "completed": "已完成", "execFailed": "执行失败", + "resultMissing": "结果记录缺失", "toolSucceeded": "成功", "toolFailed": "失败" }, @@ -1224,6 +1237,7 @@ "robots": "机器人设置", "terminal": "终端", "security": "安全设置", + "rbac": "平台权限", "audit": "日志审计", "infocollect": "信息收集" }, @@ -1392,7 +1406,7 @@ "sessionExpired": "认证已过期,请重新登录", "unauthorized": "未授权访问", "enterPassword": "请输入密码", - "loginFailedCheck": "登录失败,请检查密码", + "loginFailedCheck": "登录失败,请检查用户名或密码", "loginFailedRetry": "登录失败,请稍后重试", "loggedOut": "已退出登录" }, @@ -3454,6 +3468,7 @@ "paginationLast": "末页" }, "payloads": { + "386": "386", "title": "Payload 生成", "oneliner": "单行 Payload", "build": "编译 Beacon", @@ -3473,7 +3488,6 @@ "darwin": "macOS", "amd64": "AMD64", "arm64": "ARM64", - "386": "386", "arm": "ARM", "onelinerDesc": "快速生成可在目标机直接执行的反弹命令,支持 Bash / Python / PowerShell / Curl", "buildDesc": "交叉编译多平台完整 Beacon 可执行文件,支持 Linux / Windows / macOS", @@ -3556,5 +3570,353 @@ "confirmDelete": "确定删除此 Profile?", "submitCreate": "创建" } + }, + "rbac": { + "title": "平台权限", + "pageSubtitle": "统一管理成员、角色和可访问的数据范围。", + "createUser": "添加成员", + "memberManagement": "成员管理", + "memberManagementHint": "为成员分配角色与数据范围", + "roleManagement": "角色管理", + "roleManagementHint": "定义权限集合与默认可见范围", + "users": "用户", + "usersHint": "按账号选择后管理角色与资源范围。", + "searchUsers": "搜索用户名或显示名称", + "metricUsers": "账号", + "metricEnabled": "启用", + "metricRoles": "角色", + "metricAssignments": "资源授权", + "metricUsersPill": "个账号", + "metricEnabledPill": "个启用账号", + "metricRolesPill": "个角色", + "metricAssignmentsPill": "项授权", + "selectUser": "选择用户", + "selectUserHint": "选择一个平台用户后编辑角色、状态与资源授权。", + "createRole": "新建角色", + "tabRoles": "角色权限", + "tabAssignments": "资源授权", + "tabAudit": "变更记录", + "roleMembership": "角色成员关系", + "roleMembershipHint": "角色决定能做什么,资源范围决定能看到哪些数据。", + "unsavedChanges": "有未保存的变更", + "saveRoleChanges": "保存角色变更", + "resourceType": "资源类型", + "resourceId": "资源 ID", + "resourceIdPlaceholder": "输入资源 ID;多个 ID 用逗号分隔", + "searchResource": "搜索真实资源", + "searchResourcePlaceholder": "按名称或 ID 搜索", + "assignSelected": "授权所选资源", + "advancedAssignment": "高级:直接输入资源 ID", + "advancedAssignmentHint": "适用于从日志或 API 复制的已知 ID,提交时仍会验证资源是否存在。", + "availableResources": "可授权资源", + "availableResourcesHint": "勾选当前页资源,可跨页累计选择", + "currentAssignments": "当前单独授权", + "allResourceTypes": "全部类型", + "searchAssignments": "搜索已授权资源名称或 ID", + "items": "项", + "assignmentHelp": "仅“指定资源”范围的角色需要单独授权。项目授权会覆盖该项目下的对话和漏洞。", + "assignResource": "授权", + "roleCatalog": "角色目录", + "roleCatalogHint": "先定义角色的操作权限与资源范围,再分配给成员。", + "searchRoles": "搜索角色或权限", + "filterRoles": "筛选角色", + "userModalTitle": "平台用户", + "username": "用户名", + "displayName": "显示名称", + "password": "密码", + "passwordPlaceholder": "编辑时留空表示不修改", + "enabled": "启用账号", + "roles": "角色", + "roleModalTitle": "平台角色", + "roleName": "角色名称", + "description": "描述", + "scope": "资源范围", + "permissions": "权限", + "searchPermissions": "搜索权限,例如 project:read", + "selectVisible": "选择可见", + "clearVisible": "清空可见", + "deleteUserConfirm": "确认删除该平台用户?该操作会立即吊销该用户会话。", + "deleteBuiltinUser": "内置管理员不能删除", + "deleteRoleConfirm": "确认删除该平台角色?", + "scopeHint": "资源范围与操作权限共同决定最终访问能力。", + "builtinAccount": "内置账号", + "statusEnabled": "启用", + "statusDisabled": "停用", + "noRoleAssigned": "未分配角色", + "noExplicitGrant": "无单独授权", + "notConfigured": "未设置", + "noRoleDescription": "暂无角色说明", + "noDescription": "无描述", + "systemBuiltin": "系统内置", + "customRole": "自定义", + "operationPermissions": "操作权限", + "assignedMembers": "已分配成员", + "viewPermissions": "查看权限", + "effectivePermissions": "有效权限", + "effectiveScope": "有效资源范围", + "revoke": "撤销", + "retry": "重试", + "alreadyAssigned": "已授权", + "loadingResources": "正在加载真实资源…", + "viewSystemRole": "查看系统角色", + "editRole": "编辑角色", + "resourceCount": "{{count}} 项资源", + "permissionCount": "{{count}} 项权限", + "userCount": "{{count}} 个用户", + "confirmDiscardSwitchUser": "当前角色变更尚未保存,确定放弃并切换成员吗?", + "confirmDiscardEditUser": "当前角色变更尚未保存,确定放弃并编辑成员资料吗?", + "confirmDiscardSwitchTab": "当前角色变更尚未保存,确定放弃并切换标签吗?", + "confirmDiscardResourceType": "切换资源类型将清空当前选择,确定继续吗?", + "pendingGrants": "待授权", + "clearSelection": "清空选择", + "confirmGrant": "确认授权", + "removePending": "移除", + "selectedCount": "已选 {{count}} 项", + "noRoleNoticeTitle": "尚未分配角色", + "noRoleNoticeHint": "请先分配角色,再配置资源授权。无角色时仅可访问本人创建的数据,且大部分功能不可用。", + "assignRolesNow": "去分配角色", + "viewPermissionsDetail": "查看明细", + "hidePermissions": "收起明细", + "effectivePermissionsDetail": "有效权限明细", + "editUserRolesHint": "角色请在成员详情页的「角色权限」标签中统一管理。", + "auditTitle": "权限变更记录", + "auditHint": "展示该成员相关的平台权限操作审计。", + "unknownActor": "未知操作者", + "metricUserContext": "当前成员:{{name}} · {{roles}} 个角色 · {{grants}} 项授权", + "deleteUserConfirmDetailed": "确认删除成员 {{name}}(@{{username}})?\n\n将立即吊销其会话,并清除 {{roles}} 个角色绑定与 {{grants}} 项资源授权。", + "auditActions": { + "create_user": "创建平台用户", + "update_user": "更新平台用户", + "delete_user": "删除平台用户", + "create_role": "创建平台角色", + "update_role": "更新平台角色", + "delete_role": "删除平台角色", + "assign_resource": "授权资源访问", + "delete_resource_assignment": "撤销资源授权" + }, + "copyId": "复制 ID", + "copiedId": "已复制", + "resourceIdLabel": "ID", + "resourceDetailProject": "所属项目 {{id}}", + "pagination": { + "previous": "上一页", + "next": "下一页", + "resourcePage": "第 {{page}} 页", + "pageSummary": "第 {{page}} / {{pages}} 页 · 共 {{total}} 项", + "emptySummary": "共 0 项" + }, + "resourceTypes": { + "project": "项目", + "conversation": "对话", + "vulnerability": "漏洞", + "webshell": "WebShell 连接", + "batch_task": "批量任务", + "c2_listener": "C2 监听器" + }, + "scopes": { + "all": { + "label": "全部资源", + "hint": "可访问平台内全部数据", + "option": "全部资源 — 可访问平台内所有数据" + }, + "assigned": { + "label": "指定资源", + "hint": "仅访问管理员明确授权的数据", + "option": "指定资源 — 仅访问管理员授权的数据" + }, + "own": { + "label": "本人创建", + "hint": "仅访问自己创建的数据", + "option": "本人创建 — 仅访问自己创建的数据" + } + }, + "systemRoles": { + "admin": { "name": "管理员", "description": "全局管理权限" }, + "operator": { "name": "操作员", "description": "可执行日常安全工作流,不能管理账号与核心配置" }, + "auditor": { "name": "审计员", "description": "只读查看审计、监控与资产" }, + "viewer": { "name": "只读用户", "description": "只读查看被授权资源" } + }, + "empty": { + "noMatchingUsers": "没有匹配的成员", + "noUsers": "还没有平台成员", + "tryAnotherKeyword": "试试其他关键词", + "addFirstUser": "点击右上角“添加成员”开始配置", + "noMatchingRoles": "没有匹配的角色", + "adjustRoleSearch": "调整搜索条件或新建自定义角色", + "noRoles": "暂无角色", + "selectUserFirst": "请先选择用户", + "noAssignments": "暂无资源授权", + "addAssignmentHint": "在左侧勾选资源,或使用上方「确认授权」按钮", + "noMatchingAssignments": "没有匹配的授权", + "adjustAssignmentFilter": "调整资源类型或搜索条件", + "noMatchingResources": "没有匹配的真实资源", + "noMatchingPermissions": "没有匹配的权限", + "noEffectivePermissions": "暂无有效权限", + "assignRoleFirst": "分配角色后即可查看权限明细", + "noAuditLogs": "暂无变更记录", + "noAuditLogsHint": "该成员的角色与授权变更会显示在这里" + }, + "messages": { + "roleChangesSaved": "角色变更已保存,权限立即生效", + "grantPartial": "新增 {{created}} 项授权,{{skipped}} 项已存在或重复", + "grantSuccess": "已授权 {{count}} 项{{resource}}" + }, + "errors": { + "loadFailed": "加载失败", + "loadDataFailed": "无法加载权限数据", + "loadResourcesFailed": "加载资源失败", + "saveFailed": "保存失败", + "saveUserFailed": "保存用户失败", + "saveRoleFailed": "保存角色失败", + "deleteFailed": "删除失败", + "deleteUserFailed": "删除用户失败", + "deleteRoleFailed": "删除角色失败", + "enterResourceId": "请输入至少一个资源 ID", + "tooManyResources": "一次最多授权 100 个资源", + "batchGrantFailed": "批量授权失败", + "grantFailed": "授权失败", + "revokeFailed": "撤销失败", + "loadAuditFailed": "加载审计记录失败" + }, + "permissionModules": { + "agent": "智能体", + "agents": "智能体定义", + "attackchain": "攻击链", + "audit": "审计", + "auth": "账户安全", + "c2": "C2", + "chat": "对话", + "config": "系统配置", + "dashboard": "仪表盘", + "files": "文件", + "fofa": "FOFA", + "group": "对话分组", + "hitl": "人机协同", + "knowledge": "知识库", + "mcp": "MCP", + "monitor": "执行监控", + "notification": "通知", + "openapi": "OpenAPI", + "project": "项目", + "rbac": "平台权限", + "robot": "机器人", + "roles": "测试角色", + "skills": "Skills", + "tasks": "任务", + "terminal": "终端", + "vulnerability": "漏洞", + "webshell": "WebShell", + "workflow": "工作流" + }, + "permissionDescriptions": { + "auth": { "self": "管理自己的会话和密码" }, + "dashboard": { "read": "查看仪表盘汇总" }, + "chat": { + "read": "查看对话", + "write": "创建和更新对话", + "delete": "删除对话和消息轮次" + }, + "agent": { "execute": "运行 AI 智能体和工作流" }, + "hitl": { + "read": "查看人机协同队列和日志", + "write": "审批、驳回和配置人机协同" + }, + "tasks": { + "read": "查看任务队列", + "write": "创建和运行任务队列", + "delete": "删除任务队列" + }, + "project": { + "read": "查看项目和项目事实", + "write": "创建和更新项目及项目事实", + "delete": "删除项目和项目事实" + }, + "vulnerability": { + "read": "查看漏洞", + "write": "创建和更新漏洞", + "delete": "删除漏洞" + }, + "webshell": { + "read": "查看 WebShell 连接", + "write": "管理和使用 WebShell 连接", + "delete": "删除 WebShell 连接" + }, + "c2": { + "read": "查看 C2 监听器、会话、任务、事件和配置", + "write": "操作 C2 监听器、会话、任务、载荷、文件和配置", + "delete": "删除 C2 对象" + }, + "mcp": { + "read": "查看 MCP 状态和外部 MCP 配置", + "write": "管理外部 MCP 服务并调用 MCP 端点" + }, + "knowledge": { + "read": "查看知识库和检索日志", + "write": "创建、更新、索引和扫描知识库", + "delete": "删除知识条目和检索日志" + }, + "skills": { + "read": "查看 Skills 和统计信息", + "write": "创建和更新 Skills", + "delete": "删除 Skills 和统计信息" + }, + "agents": { + "read": "查看 Markdown 智能体", + "write": "创建和更新 Markdown 智能体", + "delete": "删除 Markdown 智能体" + }, + "roles": { + "read": "查看 AI 测试角色", + "write": "创建和更新 AI 测试角色", + "delete": "删除 AI 测试角色" + }, + "workflow": { + "read": "查看工作流定义和运行记录", + "write": "创建、更新、验证、运行和恢复工作流", + "delete": "删除工作流" + }, + "config": { + "read": "查看系统配置", + "write": "更新并应用系统配置" + }, + "terminal": { "execute": "执行终端命令" }, + "audit": { + "read": "查看和导出审计日志", + "delete": "删除审计日志" + }, + "rbac": { + "read": "查看用户、平台角色、权限和授权关系", + "write": "管理用户、平台角色、权限和授权关系" + }, + "notification": { + "read": "查看通知", + "write": "将通知标记为已读" + }, + "robot": { + "read": "查看机器人绑定状态", + "write": "管理机器人绑定并测试回调" + }, + "files": { + "read": "查看对话上传文件", + "write": "上传、编辑和重命名对话文件", + "delete": "删除对话文件" + }, + "attackchain": { + "read": "查看攻击链", + "write": "重新生成攻击链" + }, + "fofa": { "execute": "执行 FOFA 搜索和查询解析" }, + "openapi": { "read": "读取 OpenAPI 聚合结果" }, + "group": { + "read": "查看对话分组", + "write": "创建和更新对话分组", + "delete": "删除对话分组" + }, + "monitor": { + "read": "查看执行监控", + "write": "取消监控中的执行", + "delete": "删除执行监控记录" + } + } } } diff --git a/web/static/js/auth.js b/web/static/js/auth.js index ed443351..121c1aea 100644 --- a/web/static/js/auth.js +++ b/web/static/js/auth.js @@ -1,6 +1,10 @@ const AUTH_STORAGE_KEY = 'cyberstrike-auth'; let authToken = null; let authTokenExpiry = null; +let authUser = null; +let authRoles = []; +let authPermissions = new Set(); +let authScope = ''; let authPromise = null; let authPromiseResolvers = []; let isAppInitialized = false; @@ -9,28 +13,42 @@ function isTokenValid() { return !!authToken && authTokenExpiry instanceof Date && authTokenExpiry.getTime() > Date.now(); } -function saveAuth(token, expiresAt) { +function saveAuth(token, expiresAt, meta = {}) { const expiry = expiresAt instanceof Date ? expiresAt : new Date(expiresAt); authToken = token; authTokenExpiry = expiry; + authUser = meta.user || null; + authRoles = Array.isArray(meta.roles) ? meta.roles : []; + authPermissions = new Set(Array.isArray(meta.permissions) ? meta.permissions : []); + authScope = meta.scope || ''; try { localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ token, expiresAt: expiry.toISOString(), + user: authUser, + roles: authRoles, + permissions: Array.from(authPermissions), + scope: authScope, })); } catch (error) { console.warn('无法持久化认证信息:', error); } + renderUserMenuProfile(); } function clearAuthStorage() { authToken = null; authTokenExpiry = null; + authUser = null; + authRoles = []; + authPermissions = new Set(); + authScope = ''; try { localStorage.removeItem(AUTH_STORAGE_KEY); } catch (error) { console.warn('无法清除认证信息:', error); } + renderUserMenuProfile(); } function loadAuthFromStorage() { @@ -51,6 +69,10 @@ function loadAuthFromStorage() { } authToken = stored.token; authTokenExpiry = expiry; + authUser = stored.user || null; + authRoles = Array.isArray(stored.roles) ? stored.roles : []; + authPermissions = new Set(Array.isArray(stored.permissions) ? stored.permissions : []); + authScope = stored.scope || ''; return isTokenValid(); } catch (error) { console.error('读取认证信息失败:', error); @@ -68,6 +90,7 @@ function resolveAuthPromises(success) { function showLoginOverlay(message = '') { const overlay = document.getElementById('login-overlay'); const errorBox = document.getElementById('login-error'); + const usernameInput = document.getElementById('login-username'); const passwordInput = document.getElementById('login-password'); if (!overlay) { return; @@ -83,7 +106,9 @@ function showLoginOverlay(message = '') { } } setTimeout(function () { - if (passwordInput) { + if (usernameInput && !usernameInput.value) { + usernameInput.focus(); + } else if (passwordInput) { passwordInput.focus(); } }, 100); @@ -92,6 +117,7 @@ function showLoginOverlay(message = '') { function hideLoginOverlay() { const overlay = document.getElementById('login-overlay'); const errorBox = document.getElementById('login-error'); + const usernameInput = document.getElementById('login-username'); const passwordInput = document.getElementById('login-password'); closeAppModal('login-overlay'); if (errorBox) { @@ -101,6 +127,9 @@ function hideLoginOverlay() { if (passwordInput) { passwordInput.value = ''; } + if (usernameInput && !authUser) { + usernameInput.value = ''; + } } function ensureAuthPromise() { @@ -158,6 +187,13 @@ async function apiFetch(url, options = {}) { : '未授权访问'; throw new Error(msg); } + if (response.status === 403) { + const result = await response.clone().json().catch(() => ({})); + const msg = result.error || (typeof window !== 'undefined' && typeof window.t === 'function' + ? window.t('auth.forbidden') + : '权限不足'); + throw new Error(msg); + } return response; } @@ -211,6 +247,7 @@ async function apiUploadWithProgress(url, formData, options = {}) { async function submitLogin(event) { event.preventDefault(); + const usernameInput = document.getElementById('login-username'); const passwordInput = document.getElementById('login-password'); const errorBox = document.getElementById('login-error'); const submitBtn = document.querySelector('.login-submit'); @@ -219,6 +256,7 @@ async function submitLogin(event) { return; } + const username = usernameInput ? usernameInput.value.trim() : ''; const password = passwordInput.value.trim(); if (!password) { if (errorBox) { @@ -241,7 +279,7 @@ async function submitLogin(event) { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ password }), + body: JSON.stringify({ username, password }), }); const result = await response.json().catch(() => ({})); if (!response.ok || !result.token) { @@ -255,8 +293,14 @@ async function submitLogin(event) { return; } - saveAuth(result.token, result.expires_at); + saveAuth(result.token, result.expires_at, { + user: result.user, + roles: result.roles, + permissions: result.permissions, + scope: result.scope, + }); hideLoginOverlay(); + applyRBACToUI(); resolveAuthPromises(true); if (!isAppInitialized) { await bootstrapApp(); @@ -306,9 +350,151 @@ async function bootstrapApp() { initializeChatUI(); isAppInitialized = true; } + applyRBACToUI(); await refreshAppData(); } +const PAGE_PERMISSION_MAP = { + dashboard: 'dashboard:read', + chat: 'chat:read', + hitl: 'hitl:read', + 'info-collect': 'fofa:execute', + tasks: 'tasks:read', + workflows: 'workflow:read', + projects: 'project:read', + vulnerabilities: 'vulnerability:read', + 'chat-files': 'files:read', + webshell: 'webshell:read', + c2: 'c2:read', + 'c2-listeners': 'c2:read', + 'c2-sessions': 'c2:read', + 'c2-tasks': 'c2:read', + 'c2-payloads': 'c2:read', + 'c2-events': 'c2:read', + 'c2-profiles': 'c2:read', + mcp: 'mcp:read', + 'mcp-monitor': 'monitor:read', + 'mcp-management': 'mcp:read', + knowledge: 'knowledge:read', + 'knowledge-retrieval-logs': 'knowledge:read', + 'knowledge-management': 'knowledge:read', + skills: 'skills:read', + 'skills-monitor': 'skills:read', + 'skills-management': 'skills:read', + agents: 'agents:read', + 'agents-management': 'agents:read', + roles: 'roles:read', + 'roles-management': 'roles:read', + 'platform-rbac': 'rbac:read', + settings: 'config:read', +}; + +function hasPermission(permission) { + return !permission || authPermissions.has(permission); +} + +function applyRBACToUI() { + document.querySelectorAll('[data-page]').forEach((el) => { + const page = el.getAttribute('data-page'); + const permission = PAGE_PERMISSION_MAP[page]; + if (!permission) return; + const allowed = hasPermission(permission); + el.hidden = !allowed; + el.setAttribute('aria-hidden', allowed ? 'false' : 'true'); + }); + const userAvatar = document.querySelector('.user-avatar-btn'); + if (userAvatar && authUser && authUser.username) { + const displayName = getAuthDisplayName(); + userAvatar.setAttribute('title', displayName); + userAvatar.setAttribute('aria-label', authT('header.userMenuFor', '用户菜单:{{name}}', { name: displayName })); + } + renderUserMenuProfile(); +} + +function authT(key, fallback, opts = {}) { + if (typeof window !== 'undefined' && typeof window.t === 'function') { + const translated = window.t(key, opts); + if (translated && translated !== key) { + return translated; + } + } + return fallback.replace(/\{\{\s*(\w+)\s*\}\}/g, function (_, name) { + return Object.prototype.hasOwnProperty.call(opts, name) ? String(opts[name]) : ''; + }); +} + +function getAuthDisplayName() { + if (!authUser) { + return authT('header.unknownUser', '未知用户'); + } + return String(authUser.display_name || authUser.displayName || authUser.username || '').trim() + || authT('header.unknownUser', '未知用户'); +} + +function getAuthUsername() { + if (!authUser) { + return '-'; + } + const username = String(authUser.username || '').trim(); + return username ? `@${username}` : '-'; +} + +function getScopeLabel(scope) { + const normalized = String(scope || '').trim().toLowerCase(); + const keyMap = { + all: 'header.scopeAll', + assigned: 'header.scopeAssigned', + own: 'header.scopeOwn', + }; + const fallbackMap = { + all: '全部资源', + assigned: '指定资源', + own: '自己的资源', + }; + const key = keyMap[normalized] || 'header.scopeUnknown'; + const fallback = fallbackMap[normalized] || '资源范围未知'; + return authT(key, fallback); +} + +function renderUserMenuProfile() { + const displayNameEl = document.getElementById('user-menu-display-name'); + const usernameEl = document.getElementById('user-menu-username'); + const scopeEl = document.getElementById('user-menu-scope'); + const rolesEl = document.getElementById('user-menu-roles'); + const permissionsEl = document.getElementById('user-menu-permissions'); + const avatarBtn = document.getElementById('user-avatar-btn') || document.querySelector('.user-avatar-btn'); + + const displayName = getAuthDisplayName(); + const roleCount = Array.isArray(authRoles) ? authRoles.length : 0; + const permissionCount = authPermissions instanceof Set ? authPermissions.size : 0; + + if (displayNameEl) displayNameEl.textContent = displayName; + if (usernameEl) usernameEl.textContent = getAuthUsername(); + if (scopeEl) scopeEl.textContent = getScopeLabel(authScope); + if (rolesEl) rolesEl.textContent = authT('header.rolesCount', '{{count}} 个角色', { count: roleCount }); + if (permissionsEl) permissionsEl.textContent = authT('header.permissionsCount', '{{count}} 项权限', { count: permissionCount }); + if (avatarBtn && authUser) { + avatarBtn.setAttribute('title', displayName); + avatarBtn.setAttribute('aria-label', authT('header.userMenuFor', '用户菜单:{{name}}', { name: displayName })); + } else if (avatarBtn) { + avatarBtn.setAttribute('aria-label', authT('header.userMenu', '用户菜单')); + } +} + +function setUserMenuOpen(open) { + const dropdown = document.getElementById('user-menu-dropdown'); + const avatarBtn = document.getElementById('user-avatar-btn') || document.querySelector('.user-avatar-btn'); + if (!dropdown) return; + dropdown.style.display = open ? 'block' : 'none'; + if (avatarBtn) { + avatarBtn.classList.toggle('active', open); + avatarBtn.setAttribute('aria-expanded', open ? 'true' : 'false'); + } + if (open) { + renderUserMenuProfile(); + } +} + // 通用工具函数 function getStatusText(status) { const s = (status && String(status).toLowerCase()) || ''; @@ -366,7 +552,15 @@ async function initializeApp() { method: 'GET', }); if (response.ok) { + const result = await response.json().catch(() => ({})); + saveAuth(result.token || authToken, result.expires_at || authTokenExpiry, { + user: result.user || authUser, + roles: result.roles || authRoles, + permissions: result.permissions || Array.from(authPermissions), + scope: result.scope || authScope, + }); hideLoginOverlay(); + applyRBACToUI(); resolveAuthPromises(true); await bootstrapApp(); return; @@ -386,7 +580,7 @@ function toggleUserMenu() { if (!dropdown) return; const isVisible = dropdown.style.display !== 'none'; - dropdown.style.display = isVisible ? 'none' : 'block'; + setUserMenuOpen(!isVisible); } // 点击页面其他地方时关闭下拉菜单 @@ -397,17 +591,18 @@ document.addEventListener('click', function(event) { if (dropdown && avatarBtn && !dropdown.contains(event.target) && !avatarBtn.contains(event.target)) { - dropdown.style.display = 'none'; + setUserMenuOpen(false); } }); +document.addEventListener('languagechange', function () { + renderUserMenuProfile(); +}); + // 退出登录 async function logout() { // 关闭下拉菜单 - const dropdown = document.getElementById('user-menu-dropdown'); - if (dropdown) { - dropdown.style.display = 'none'; - } + setUserMenuOpen(false); try { // 先尝试调用退出API(如果token有效) @@ -434,5 +629,7 @@ async function logout() { // 导出函数供HTML使用 window.toggleUserMenu = toggleUserMenu; window.logout = logout; +window.hasPermission = hasPermission; +window.applyRBACToUI = applyRBACToUI; document.addEventListener('DOMContentLoaded', initializeApp); diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 01e8c525..3a989b98 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -2458,6 +2458,32 @@ function syncProcessDetailButtonLabels(messageId, expanded) { }); } +/** 懒加载占位提示可点击,与工具栏「展开详情」行为一致 */ +function bindProcessDetailsLazyHint(hostEl, messageId) { + if (!hostEl || !messageId) return; + const emptyEl = hostEl.classList && hostEl.classList.contains('progress-timeline-empty') + ? hostEl + : hostEl.querySelector('.progress-timeline-empty'); + if (!emptyEl || emptyEl.dataset.lazyHintBound === '1') return; + emptyEl.dataset.lazyHintBound = '1'; + emptyEl.classList.add('progress-timeline-lazy-clickable'); + emptyEl.setAttribute('role', 'button'); + emptyEl.setAttribute('tabindex', '0'); + const activate = () => { + if (typeof toggleProcessDetails === 'function') { + toggleProcessDetails(null, messageId); + } + }; + emptyEl.addEventListener('click', activate); + emptyEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + activate(); + } + }); +} +window.bindProcessDetailsLazyHint = bindProcessDetailsLazyHint; + // 渲染过程详情 // options.append=true 时分页追加;options.markLoaded=false 时保留 lazy 标记(分页加载中) function renderProcessDetails(messageId, processDetails, options) { @@ -2465,6 +2491,13 @@ function renderProcessDetails(messageId, processDetails, options) { const appendMode = !!renderOpts.append; const prependMode = !!renderOpts.prepend; const markLoaded = renderOpts.markLoaded !== false; + const toolStatusByProcessDetailId = new Map(); + if (Array.isArray(renderOpts.toolExecutions)) { + renderOpts.toolExecutions.forEach((execution) => { + if (!execution || !execution.processDetailId) return; + toolStatusByProcessDetailId.set(String(execution.processDetailId), String(execution.status || '').toLowerCase()); + }); + } const messageElement = document.getElementById(messageId); if (!messageElement) { return; @@ -2529,6 +2562,7 @@ function renderProcessDetails(messageId, processDetails, options) { const expandLabel = typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情'; let lazyHint = expandLabel + '(点击后加载迭代详情)'; timeline.innerHTML = '
' + lazyHint + '
'; + bindProcessDetailsLazyHint(timeline, messageId); timeline.classList.remove('expanded'); prefetchProcessDetailsSummaryHint(messageId, messageElement); return; @@ -2725,6 +2759,9 @@ function renderProcessDetails(messageId, processDetails, options) { processDetailId: detail.id || '', createdAt: detail.createdAt }; + if (eventType === 'tool_call' && detail.id && toolStatusByProcessDetailId.has(String(detail.id))) { + timelineOpts.toolStatus = toolStatusByProcessDetailId.get(String(detail.id)); + } if (eventType === 'tool_call' && data._mergedResult) { timelineOpts.mergedResult = data._mergedResult; } @@ -2781,6 +2818,7 @@ function finishProcessDetailsRender(messageElement, processDetails, isLazyNotLoa lazyHint.textContent = (typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') + '(点击后加载完整过程详情)'; timeline.appendChild(lazyHint); + bindProcessDetailsLazyHint(lazyHint, messageElement.id); } const hasPendingHitlInDetails = processDetails.some(d => d && d.eventType === 'hitl_interrupt'); @@ -2841,6 +2879,7 @@ function prefetchProcessDetailsSummaryHint(messageId, messageElement) { const empty = timeline.querySelector('.progress-timeline-empty'); if (empty) { empty.textContent = hint; + bindProcessDetailsLazyHint(timeline, messageId); } }) .catch(() => {}); @@ -2999,16 +3038,42 @@ function normalizeToolExecutionSummaryForButton(raw) { }; } -function setPendingToolExecutionSummaries(messageElement, summaries) { - if (!messageElement || !messageElement.dataset || !Array.isArray(summaries)) return; +function cacheToolExecutionSummaries(messageElement, summaries) { + if (!messageElement || !messageElement.dataset || !Array.isArray(summaries)) return []; const normalized = summaries .map(normalizeToolExecutionSummaryForButton) .filter((item) => item.toolName || item.executionId || item.toolCallId); + if (normalized.length > 0) { + messageElement.dataset.toolExecutionSummaries = JSON.stringify(normalized); + } + return normalized; +} + +function getCachedToolExecutionSummaries(messageElement) { + if (!messageElement || !messageElement.dataset || !messageElement.dataset.toolExecutionSummaries) return []; + try { + const parsed = JSON.parse(messageElement.dataset.toolExecutionSummaries); + return Array.isArray(parsed) ? parsed.map(normalizeToolExecutionSummaryForButton) : []; + } catch (e) { + delete messageElement.dataset.toolExecutionSummaries; + return []; + } +} + +function setPendingToolExecutionSummaries(messageElement, summaries) { + if (!messageElement || !messageElement.dataset || !Array.isArray(summaries)) return; + const normalized = cacheToolExecutionSummaries(messageElement, summaries); if (normalized.length > 0) { messageElement.dataset.pendingToolExecutionSummaries = JSON.stringify(normalized); } else { delete messageElement.dataset.pendingToolExecutionSummaries; } + const renderedToolList = messageElement.querySelector('.mcp-tool-list'); + if (normalized.length > 0 && renderedToolList && renderedToolList.querySelector('.mcp-detail-btn[data-exec-id], .mcp-detail-btn[data-tool-summary]')) { + appendMcpCallSummaryButtons(messageElement, normalized); + delete messageElement.dataset.pendingToolExecutionSummaries; + delete messageElement.dataset.pendingMcpExecutionIds; + } if (typeof syncMcpToolsToggleButton === 'function') { syncMcpToolsToggleButton(messageElement); } @@ -3202,6 +3267,34 @@ async function focusToolExecutionInProcessDetails(messageElement, summary, index }, 2200); } +async function resolveToolExecutionSummaryForFocus(messageElement, executionId, index) { + const wantedExecutionId = executionId == null ? '' : String(executionId).trim(); + let summaries = getCachedToolExecutionSummaries(messageElement); + let item = wantedExecutionId + ? summaries.find((summary) => summary.executionId === wantedExecutionId) + : summaries[index]; + if (item && (item.processDetailId || item.toolCallId)) return item; + + const backendId = messageElement && messageElement.dataset + ? String(messageElement.dataset.backendMessageId || '').trim() + : ''; + if (!backendId || typeof apiFetch !== 'function') return item || null; + try { + const res = await apiFetch('/api/messages/' + encodeURIComponent(backendId) + '/process-details?summary=1'); + const payload = await res.json().catch(() => ({})); + if (!res.ok || !payload.summary || !Array.isArray(payload.summary.toolExecutions)) { + return item || null; + } + summaries = cacheToolExecutionSummaries(messageElement, payload.summary.toolExecutions); + item = wantedExecutionId + ? summaries.find((summary) => summary.executionId === wantedExecutionId) + : summaries[index]; + return item || null; + } catch (e) { + return item || null; + } +} + function toggleMcpToolList(assistantMessageId) { const messageEl = document.getElementById(assistantMessageId); if (!messageEl) return; @@ -3255,7 +3348,14 @@ function appendMcpCallButtons(messageElement, executionIds) { detailBtn.dataset.execId = execId; detailBtn.dataset.execIndex = String(index + 1); detailBtn.innerHTML = '' + (typeof window.t === 'function' ? window.t('chat.callNumber', { n: index + 1 }) : '调用 #' + (index + 1)) + ''; - detailBtn.onclick = () => showMCPDetail(execId); + detailBtn.onclick = async () => { + const summary = await resolveToolExecutionSummaryForFocus(messageElement, execId, index); + if (summary && (summary.processDetailId || summary.toolCallId)) { + await focusToolExecutionInProcessDetails(messageElement, summary, index); + return; + } + showMCPDetail(execId); + }; toolList.appendChild(detailBtn); }); batchUpdateButtonToolNames(toolList, executionIds); @@ -3273,10 +3373,16 @@ function appendMcpCallSummaryButtons(messageElement, summaries) { const item = normalizeToolExecutionSummaryForButton(raw); const key = item.executionId || item.toolCallId || `${item.toolName || 'tool'}-${index + 1}`; const selector = '.mcp-detail-btn[data-tool-summary="' + CSS.escape(String(key)) + '"]'; - if (toolList.querySelector(selector) || (item.executionId && toolList.querySelector('.mcp-detail-btn[data-exec-id="' + CSS.escape(String(item.executionId)) + '"]'))) { + const existingSummaryBtn = toolList.querySelector(selector); + const existingExecBtn = item.executionId + ? toolList.querySelector('.mcp-detail-btn[data-exec-id="' + CSS.escape(String(item.executionId)) + '"]') + : null; + if (existingSummaryBtn) { return; } - const btn = document.createElement('button'); + // 历史会话可能先按 executionId 渲染旧按钮,随后摘要才异步到达。 + // 复用并升级该按钮,避免它永久保留“只看执行详情、不定位上下文”的旧处理器。 + const btn = existingExecBtn || document.createElement('button'); btn.className = 'mcp-detail-btn'; btn.dataset.toolSummary = key; if (item.executionId) { @@ -3294,7 +3400,9 @@ function appendMcpCallSummaryButtons(messageElement, summaries) { }; } renderToolExecutionButtonContent(btn, item.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具'), String(index + 1), item.status); - toolList.appendChild(btn); + if (!existingExecBtn) { + toolList.appendChild(btn); + } }); syncMcpToolsToggleButton(messageElement); } @@ -3363,7 +3471,8 @@ function getToolExecutionStatusLabel(status) { failed: 'mcpMonitor.statusFailed', running: 'mcpMonitor.statusRunning', cancelled: 'mcpMonitor.statusCancelled', - pending: 'mcpMonitor.statusPending' + pending: 'mcpMonitor.statusPending', + result_missing: 'timeline.resultMissing' }; const key = keyMap[normalized]; if (key) { @@ -3376,7 +3485,8 @@ function getToolExecutionStatusLabel(status) { failed: '失败', running: '运行中', cancelled: '已取消', - pending: '等待中' + pending: '等待中', + result_missing: '结果记录缺失' }; return fallback[normalized] || ''; } diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index 6a3d7345..45db2f4b 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -1357,6 +1357,10 @@ function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecut `; + if (typeof window.bindProcessDetailsLazyHint === 'function') { + const tl = detailsContainer.querySelector('.progress-timeline'); + if (tl) window.bindProcessDetailsLazyHint(tl, assistantMessageId); + } } const expandLabel = typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情'; @@ -1492,11 +1496,13 @@ async function loadProcessDetailsPaginated(assistantMessageId, backendMessageId, throw new Error((j && j.error) ? j.error : String(res.status)); } const details = (j && Array.isArray(j.processDetails)) ? j.processDetails : []; + const toolExecutions = (j && Array.isArray(j.toolExecutions)) ? j.toolExecutions : []; const hasMore = !!(j && j.hasMore); renderProcessDetails(assistantMessageId, details, { append: !isFirst || opts.append, prepend: prepend, - markLoaded: autoLoadAll ? !hasMore : true + markLoaded: autoLoadAll ? !hasMore : true, + toolExecutions: toolExecutions }); const responseOffset = j && typeof j.offset === 'number' ? j.offset : offset; const total = j && typeof j.total === 'number' ? j.total : responseOffset + details.length; @@ -1525,6 +1531,81 @@ async function loadProcessDetailsPaginated(assistantMessageId, backendMessageId, window.loadProcessDetailsPaginated = loadProcessDetailsPaginated; +function resolveEventBackendMessageId(eventData) { + if (!eventData || typeof eventData !== 'object') return ''; + const raw = eventData.messageId != null ? eventData.messageId : eventData.assistantMessageId; + return raw != null ? String(raw).trim() : ''; +} + +function triggerLazyProcessDetailsLoad(assistantMessageId, backendMessageId, detailsContainer) { + if (!assistantMessageId || !backendMessageId || !detailsContainer) return false; + if (detailsContainer.dataset.loading === '1') return false; + const collapseT = typeof window.t === 'function' ? window.t('tasks.collapseDetail') : '收起详情'; + detailsContainer.dataset.loading = '1'; + const timeline = detailsContainer.querySelector('.progress-timeline'); + if (timeline) { + timeline.innerHTML = '
' + ((typeof window.t === 'function') ? window.t('common.loading') : '加载中…') + '
'; + } + loadProcessDetailsPaginated(assistantMessageId, backendMessageId, { autoLoadAll: false }) + .catch((e) => { + console.error('加载过程详情失败:', e); + const tl = detailsContainer.querySelector('.progress-timeline'); + if (tl) { + tl.innerHTML = '
' + ((typeof window.t === 'function') ? window.t('chat.noProcessDetail') : '暂无过程详情(加载失败)') + '
'; + if (typeof window.bindProcessDetailsLazyHint === 'function') { + window.bindProcessDetailsLazyHint(tl, assistantMessageId); + } + } + detailsContainer.dataset.lazyNotLoaded = '1'; + detailsContainer.dataset.loaded = '0'; + }) + .finally(() => { + detailsContainer.dataset.loading = '0'; + if (detailsContainer.dataset.userExpanded === '1') { + const tl = detailsContainer.querySelector('.progress-timeline'); + if (tl) { + tl.classList.add('expanded'); + } + if (typeof syncProcessDetailButtonLabels === 'function') { + syncProcessDetailButtonLabels(assistantMessageId, true); + } else { + document.querySelectorAll('#' + assistantMessageId + ' .process-detail-btn').forEach((btn) => { + btn.innerHTML = '' + collapseT + ''; + }); + } + } + }); + return true; +} + +function maybeReloadLazyProcessDetails(assistantMessageId) { + const detailsContainer = document.getElementById('process-details-' + assistantMessageId); + if (!detailsContainer) return; + const isLazy = detailsContainer.dataset.lazyNotLoaded === '1' && detailsContainer.dataset.loaded !== '1'; + if (!isLazy) return; + const timeline = detailsContainer.querySelector('.progress-timeline'); + const wantsExpanded = detailsContainer.dataset.userExpanded === '1' || + !!(timeline && timeline.classList.contains('expanded')); + if (!wantsExpanded) return; + const messageEl = document.getElementById(assistantMessageId); + const backendId = messageEl && messageEl.dataset ? String(messageEl.dataset.backendMessageId || '').trim() : ''; + if (!backendId) return; + triggerLazyProcessDetailsLoad(assistantMessageId, backendId, detailsContainer); +} + +function scheduleProcessDetailsLoadWhenReady(assistantMessageId, detailsContainer, attempt) { + if (!assistantMessageId || !detailsContainer) return; + const tries = typeof attempt === 'number' ? attempt : 0; + if (tries > 25) return; + const messageEl = document.getElementById(assistantMessageId); + const backendId = messageEl && messageEl.dataset ? String(messageEl.dataset.backendMessageId || '').trim() : ''; + if (backendId) { + triggerLazyProcessDetailsLoad(assistantMessageId, backendId, detailsContainer); + return; + } + setTimeout(() => scheduleProcessDetailsLoadWhenReady(assistantMessageId, detailsContainer, tries + 1), 200); +} + // 切换过程详情显示 function toggleProcessDetails(progressId, assistantMessageId) { const detailsId = 'process-details-' + assistantMessageId; @@ -1539,42 +1620,10 @@ function toggleProcessDetails(progressId, assistantMessageId) { if (maybeLazy) { const messageEl = document.getElementById(assistantMessageId); const backendMessageId = messageEl && messageEl.dataset ? messageEl.dataset.backendMessageId : ''; - if (backendMessageId && typeof apiFetch === 'function' && typeof renderProcessDetails === 'function') { - if (detailsContainer.dataset.loading === '1') { - // 正在加载中,避免重复请求 - } else { - detailsContainer.dataset.loading = '1'; - const timeline = detailsContainer.querySelector('.progress-timeline'); - if (timeline) { - timeline.innerHTML = '
' + ((typeof window.t === 'function') ? window.t('common.loading') : '加载中…') + '
'; - } - loadProcessDetailsPaginated(assistantMessageId, backendMessageId, { autoLoadAll: false }) - .catch((e) => { - console.error('加载过程详情失败:', e); - const tl = detailsContainer.querySelector('.progress-timeline'); - if (tl) { - tl.innerHTML = '
' + ((typeof window.t === 'function') ? window.t('chat.noProcessDetail') : '暂无过程详情(加载失败)') + '
'; - } - detailsContainer.dataset.lazyNotLoaded = '1'; - detailsContainer.dataset.loaded = '0'; - }) - .finally(() => { - detailsContainer.dataset.loading = '0'; - if (detailsContainer.dataset.userExpanded === '1') { - const tl = detailsContainer.querySelector('.progress-timeline'); - if (tl) { - tl.classList.add('expanded'); - } - if (typeof syncProcessDetailButtonLabels === 'function') { - syncProcessDetailButtonLabels(assistantMessageId, true); - } else { - document.querySelectorAll('#' + assistantMessageId + ' .process-detail-btn').forEach((btn) => { - btn.innerHTML = '' + collapseT + ''; - }); - } - } - }); - } + if (backendMessageId) { + triggerLazyProcessDetailsLoad(assistantMessageId, backendMessageId, detailsContainer); + } else { + scheduleProcessDetailsLoadWhenReady(assistantMessageId, detailsContainer, 0); } } @@ -1647,6 +1696,7 @@ function applyBackendMessageIdToAssistantDom(domAssistantId, backendMessageId) { if (typeof attachDeleteTurnButton === 'function') { attachDeleteTurnButton(el); } + maybeReloadLazyProcessDetails(domAssistantId); } /** 将后端用户消息 ID 绑定到最后一条尚未绑定 backendMessageId 的用户气泡 */ @@ -2480,7 +2530,7 @@ function handleStreamEvent(event, progressElement, progressId, // 复用已有助手消息(若有),避免终态事件重复插入消息 { - const preferredMessageId = event.data && event.data.messageId ? event.data.messageId : null; + const preferredMessageId = resolveEventBackendMessageId(event.data) || null; const { assistantId, assistantElement } = upsertTerminalAssistantMessage(event.message, preferredMessageId); if (assistantId && preferredMessageId) { applyBackendMessageIdToAssistantDom(assistantId, preferredMessageId); @@ -2489,6 +2539,8 @@ function handleStreamEvent(event, progressElement, progressId, const detailsId = 'process-details-' + assistantId; if (!document.getElementById(detailsId)) { integrateProgressToMCPSection(progressId, assistantId, typeof getMcpIds === 'function' ? (getMcpIds() || []) : []); + } else if (preferredMessageId) { + maybeReloadLazyProcessDetails(assistantId); } setTimeout(() => { collapseAllProgressDetails(assistantId, progressId); @@ -2501,7 +2553,7 @@ function handleStreamEvent(event, progressElement, progressId, // Close any remaining running tool calls for this progress. finalizeOutstandingToolCallsForProgress(progressId, 'failed'); break; - + case 'response_start': { const responseTaskState = progressTaskState.get(progressId); const responseOriginalConversationId = responseTaskState?.conversationId; @@ -2721,7 +2773,7 @@ function handleStreamEvent(event, progressElement, progressId, // 复用已有助手消息(若有),避免终态事件重复插入消息 { - const preferredMessageId = event.data && event.data.messageId ? event.data.messageId : null; + const preferredMessageId = resolveEventBackendMessageId(event.data) || null; const { assistantId, assistantElement } = upsertTerminalAssistantMessage(event.message, preferredMessageId); if (assistantId && preferredMessageId) { applyBackendMessageIdToAssistantDom(assistantId, preferredMessageId); @@ -2730,6 +2782,8 @@ function handleStreamEvent(event, progressElement, progressId, const detailsId = 'process-details-' + assistantId; if (!document.getElementById(detailsId)) { integrateProgressToMCPSection(progressId, assistantId, typeof getMcpIds === 'function' ? (getMcpIds() || []) : []); + } else if (preferredMessageId) { + maybeReloadLazyProcessDetails(assistantId); } setTimeout(() => { collapseAllProgressDetails(assistantId, progressId); @@ -3961,6 +4015,7 @@ function addTimelineItem(timeline, type, options) { item.dataset.toolCallId = String(d.toolCallId).trim(); } const merged = options.mergedResult || d._mergedResult; + const terminalStatus = String(options.toolStatus || '').toLowerCase(); if (merged) { item.dataset.toolResultMerged = '1'; item.dataset.toolSuccess = merged.success !== false ? '1' : '0'; @@ -3968,6 +4023,12 @@ function addTimelineItem(timeline, type, options) { if (d._mergedResultDetailId) { item.dataset.toolResultDetailId = String(d._mergedResultDetailId); } + } else if (terminalStatus === 'completed' || terminalStatus === 'failed') { + item.dataset.toolSuccess = terminalStatus === 'completed' ? '1' : '0'; + item.classList.add(terminalStatus === 'completed' ? 'tool-call-completed' : 'tool-call-failed'); + } else if (terminalStatus === 'result_missing') { + item.classList.add('tool-call-incomplete'); + item.title = typeof window.t === 'function' ? window.t('timeline.resultMissing') : '结果记录缺失'; } } if (type === 'hitl_interrupt' && options.data && options.data.interruptId != null && String(options.data.interruptId).trim() !== '') { @@ -4041,19 +4102,26 @@ function addTimelineItem(timeline, type, options) { const data = options.data; const args = parseToolCallArgsFromData(data); const merged = options.mergedResult || data._mergedResult; + const terminalStatus = String(options.toolStatus || '').toLowerCase(); + const hasTerminalStatus = terminalStatus === 'completed' || terminalStatus === 'failed'; + const hasHistoricalStatus = hasTerminalStatus || terminalStatus === 'result_missing'; if (merged) { if (merged.success !== false) { item.classList.add('tool-call-completed'); } else { item.classList.add('tool-call-failed'); } + } else if (hasTerminalStatus) { + item.classList.add(terminalStatus === 'completed' ? 'tool-call-completed' : 'tool-call-failed'); + } else if (terminalStatus === 'result_missing') { + item.classList.add('tool-call-incomplete'); } else if (!options.skipPendingResult) { item.classList.add('tool-call-running'); } setToolCallDetailState(item, { args: args, resultData: merged || null, - pending: !merged && !options.skipPendingResult, + pending: !merged && !hasHistoricalStatus && !options.skipPendingResult, processDetailId: options.processDetailId || '', resultDetailId: data._mergedResultDetailId || '', payloadDeferred: data._payloadDeferred === true || (merged && merged._payloadDeferred === true), diff --git a/web/static/js/rbac.js b/web/static/js/rbac.js new file mode 100644 index 00000000..6336dff7 --- /dev/null +++ b/web/static/js/rbac.js @@ -0,0 +1,1329 @@ +let rbacState = { + users: [], + roles: [], + permissions: {}, + assignments: [], + selectedUserId: '', + mainView: 'users', + activeTab: 'roles', + userSearch: '', + roleSearch: '', + permissionSearch: '', + rolePermissionSelection: new Set(), + resourceOptions: [], + selectedResourceIds: new Set(), + selectedResourceMeta: new Map(), + resourceSearchTimer: null, + resourceRequestSeq: 0, + resourcePage: 0, + resourcePageSize: 12, + resourceHasMore: false, + assignmentSearch: '', + assignmentType: 'all', + assignmentPage: 0, + assignmentPageSize: 8, + assignmentResourceType: 'conversation', + showEffectivePermissions: false, + auditLogs: [], + auditLoading: false, + pendingRoleUserId: '', + pendingUserRoles: new Set(), + editingRoleIsSystem: false, + savingRoleId: '', +}; + +const rbacScopeMeta = { + all: { tone: 'is-warn' }, + assigned: { tone: 'is-info' }, + own: { tone: '' }, +}; + +const rbacResourceLabels = { + project: '项目', conversation: '对话', vulnerability: '漏洞', webshell: 'WebShell 连接', + batch_task: '批量任务', c2_listener: 'C2 监听器', +}; + +function rbacText(value, fallback = '') { + return value == null || value === '' ? fallback : String(value); +} + +function rbacEscape(value) { + if (typeof escapeHtml === 'function') return escapeHtml(rbacText(value)); + const div = document.createElement('div'); + div.textContent = rbacText(value); + return div.innerHTML; +} + +function rbacT(key, fallback) { + const options = arguments.length > 2 ? arguments[2] : undefined; + const translator = typeof window !== 'undefined' && typeof window.t === 'function' ? window.t : null; + if (translator) { + const translated = translator(key, options); + if (translated && translated !== key) return translated; + } + return fallback; +} + +function rbacPermissionLabel(key) { + const parts = String(key || '').split(':'); + return rbacT(`rbac.permissionDescriptions.${parts[0] || 'other'}.${parts[1] || 'unknown'}`, rbacState.permissions[key] || key); +} + +function rbacPermissionModuleLabel(module) { + return rbacT(`rbac.permissionModules.${module}`, module); +} + +function rbacResourceLabel(type) { + return rbacT(`rbac.resourceTypes.${type}`, rbacResourceLabels[type] || type); +} + +function rbacRoleName(role) { + if (!role) return ''; + return rbacRoleIsSystem(role) ? rbacT(`rbac.systemRoles.${role.id}.name`, role.name) : role.name; +} + +function rbacRoleDescription(role) { + if (!role) return ''; + return rbacRoleIsSystem(role) ? rbacT(`rbac.systemRoles.${role.id}.description`, role.description || '') : (role.description || ''); +} + +function rbacShortId(value) { + const id = rbacText(value); + if (id.length <= 16) return id; + return `${id.slice(0, 8)}…${id.slice(-4)}`; +} + +function rbacFormatResourceDetail(type, detail) { + const text = rbacText(detail); + if (!text) return ''; + if (type === 'conversation' && text) { + return rbacT('rbac.resourceDetailProject', '所属项目 {{id}}', { id: rbacShortId(text) }); + } + return text; +} + +async function rbacCopyResourceId(id) { + const value = rbacText(id); + if (!value) return; + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(value); + } else { + const input = document.createElement('textarea'); + input.value = value; + document.body.appendChild(input); + input.select(); + document.execCommand('copy'); + document.body.removeChild(input); + } + rbacNotify(rbacT('rbac.copiedId', '已复制'), 'success'); + } catch (error) { + rbacNotify(`${rbacT('rbac.copyId', '复制 ID')}: ${error.message || error}`, 'error'); + } +} + +function rbacPendingSelectionCount() { + const manualIds = document.getElementById('rbac-assignment-id')?.value.split(/[\s,,;;]+/).filter(Boolean) || []; + return new Set([...rbacState.selectedResourceIds, ...manualIds]).size; +} + +function rbacRememberSelectedResource(resource) { + if (!resource || !resource.id) return; + rbacState.selectedResourceMeta.set(resource.id, { + label: resource.label || resource.id, + detail: resource.detail || '', + type: document.getElementById('rbac-assignment-type')?.value || '', + }); +} + +function rbacSelectedResourceMeta(resourceId) { + if (rbacState.selectedResourceMeta.has(resourceId)) { + return rbacState.selectedResourceMeta.get(resourceId); + } + const cached = rbacState.resourceOptions.find(item => item.id === resourceId); + if (cached) { + return { + label: cached.label || cached.id, + detail: cached.detail || '', + type: document.getElementById('rbac-assignment-type')?.value || '', + }; + } + return { label: resourceId, detail: '', type: document.getElementById('rbac-assignment-type')?.value || '' }; +} + +function clearRbacResourceSelection() { + rbacState.selectedResourceIds.clear(); + rbacState.selectedResourceMeta.clear(); + const manual = document.getElementById('rbac-assignment-id'); + if (manual) manual.value = ''; + renderRbacResourcePicker(); + syncRbacAssignmentSubmit(); +} + +function renderRbacNoRoleNotice(user) { + const box = document.getElementById('rbac-no-role-notice'); + if (!box) return; + const hasRoles = user && rbacUserRoles(user).length > 0; + if (!user || hasRoles) { + box.hidden = true; + box.innerHTML = ''; + return; + } + box.hidden = false; + box.innerHTML = ` +
+ ${rbacEscape(rbacT('rbac.noRoleNoticeTitle', '尚未分配角色'))} + ${rbacEscape(rbacT('rbac.noRoleNoticeHint', '请先分配角色,再配置资源授权。无角色时仅可访问本人创建的数据,且大部分功能不可用。'))} +
+ `; +} + +function renderRbacPendingSelection() { + const bar = document.getElementById('rbac-pending-bar'); + const list = document.getElementById('rbac-pending-list'); + const count = document.getElementById('rbac-pending-count'); + const manualIds = document.getElementById('rbac-assignment-id')?.value.split(/[\s,,;;]+/).map(id => id.trim()).filter(Boolean) || []; + const selectedIds = Array.from(rbacState.selectedResourceIds); + const total = new Set([...selectedIds, ...manualIds]).size; + if (count) count.textContent = String(total); + if (!bar || !list) return; + if (total === 0) { + bar.hidden = true; + list.innerHTML = ''; + return; + } + bar.hidden = false; + const resourceType = document.getElementById('rbac-assignment-type')?.value || ''; + const items = [ + ...selectedIds.map(id => ({ id, meta: rbacSelectedResourceMeta(id), manual: false })), + ...manualIds.filter(id => !rbacState.selectedResourceIds.has(id)).map(id => ({ id, meta: { label: id, detail: rbacT('rbac.advancedAssignment', '高级:直接输入资源 ID'), type: resourceType }, manual: true })), + ]; + list.innerHTML = items.map(item => ` +
+
+ ${rbacEscape(item.meta.label || item.id)} + ${rbacEscape(rbacShortId(item.id))} +
+ ${item.manual ? '' : ``} +
`).join(''); +} + +function rbacNotify(message, type = 'info') { + if (typeof showNotification === 'function') showNotification(message, type); + else if (type === 'error') alert(message); +} + +function rbacUserDisplayName(user) { + return (user && (user.displayName || user.display_name || user.username)) || ''; +} + +function rbacUserIsBuiltin(user) { + return !!(user && (user.isBuiltin || user.is_builtin || user.id === 'admin')); +} + +function rbacUserRoles(user) { + return Array.isArray(user && user.roles) ? user.roles : []; +} + +function resolveUserEffectiveAccess(user) { + const userRoles = user ? rbacUserRoles(user) : []; + const effectivePermissions = new Set(); + const permissionsByModule = {}; + let effectiveScope = 'own'; + userRoles.forEach(roleId => { + const role = rbacState.roles.find(r => r.id === roleId); + (role && role.permissions || []).forEach(permission => { + effectivePermissions.add(permission); + const module = permission.split(':')[0] || 'other'; + if (!permissionsByModule[module]) permissionsByModule[module] = []; + permissionsByModule[module].push(permission); + }); + if (role && role.scope === 'all') effectiveScope = 'all'; + else if (role && role.scope === 'assigned' && effectiveScope !== 'all') effectiveScope = 'assigned'; + }); + const assignmentCount = user + ? rbacState.assignments.filter(a => (a.userId || a.user_id) === user.id).length + : 0; + return { userRoles, effectivePermissions, permissionsByModule, effectiveScope, assignmentCount }; +} + +function toggleRbacEffectivePermissions() { + rbacState.showEffectivePermissions = !rbacState.showEffectivePermissions; + renderRbacEffectivePermissions(selectedRbacUser()); +} + +function renderRbacEffectivePermissions(user) { + const box = document.getElementById('rbac-effective-permissions'); + if (!box) return; + if (!user || !rbacState.showEffectivePermissions) { + box.hidden = true; + box.innerHTML = ''; + return; + } + const access = resolveUserEffectiveAccess(user); + const modules = Object.keys(access.permissionsByModule).sort(); + if (!modules.length) { + box.hidden = false; + box.innerHTML = `
${rbacT('rbac.empty.noEffectivePermissions', '暂无有效权限')}${rbacEscape(rbacT('rbac.empty.assignRoleFirst', '分配角色后即可查看权限明细'))}
`; + return; + } + box.hidden = false; + box.innerHTML = ` +
+ ${rbacEscape(rbacT('rbac.effectivePermissionsDetail', '有效权限明细'))} + +
+
+ ${modules.map(module => ` +
+

${rbacEscape(rbacPermissionModuleLabel(module))}${access.permissionsByModule[module].length}

+
+ ${access.permissionsByModule[module].sort().map(key => `${rbacEscape(rbacPermissionLabel(key))}`).join('')} +
+
`).join('')} +
`; +} + +function rbacRoleIsSystem(role) { + return !!(role && (role.isSystem || role.is_system)); +} + +async function rbacRun(action, fallbackMessage) { + try { + await action(); + return true; + } catch (error) { + rbacNotify(`${fallbackMessage}: ${error.message || error}`, 'error'); + return false; + } +} + +async function initPlatformRbacPage() { + if (typeof window !== 'undefined' && window.i18nReady) await window.i18nReady; + const typeInput = document.getElementById('rbac-assignment-type'); + if (typeInput) rbacState.assignmentResourceType = typeInput.value || 'conversation'; + await rbacRun(loadPlatformRbac, rbacT('rbac.errors.loadFailed', '加载失败')); + initRbacSelects(); +} + +function initRbacSelects() { + if (typeof window.initSettingsCustomSelects !== 'function') return; + ['page-platform-rbac', 'rbac-user-modal', 'rbac-role-modal'].forEach(id => { + const root = document.getElementById(id); + if (root) window.initSettingsCustomSelects(root); + }); + if (typeof window.refreshSettingsCustomSelects === 'function') { + window.refreshSettingsCustomSelects(); + } +} + +async function refreshRbacAssignments() { + const res = await apiFetch('/api/rbac/resource-assignments'); + const result = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(result.error || rbacT('rbac.errors.loadDataFailed', '无法加载权限数据')); + rbacState.assignments = result.assignments || []; + renderRbacOverview(); + renderRbacUsers(); + renderRbacAssignments(); +} + +async function refreshRbacUserRoles(userId, roles) { + const user = rbacState.users.find(item => item.id === userId); + if (user) user.roles = roles; + rbacState.pendingRoleUserId = ''; + ensurePendingUserRoles(selectedRbacUser()); + renderRbacOverview(); + renderRbacUsers(); + renderRbacRoles(); +} + +async function loadPlatformRbac() { + const responses = await Promise.all([ + apiFetch('/api/rbac/metadata'), + apiFetch('/api/rbac/users'), + apiFetch('/api/rbac/roles'), + apiFetch('/api/rbac/resource-assignments'), + ]); + const payloads = await Promise.all(responses.map(response => response.json().catch(() => ({})))); + const failedIndex = responses.findIndex(response => !response.ok); + if (failedIndex >= 0) { + throw new Error(payloads[failedIndex].error || rbacT('rbac.errors.loadDataFailed', '无法加载权限数据')); + } + const [meta, users, roles, assignments] = payloads; + rbacState.permissions = meta.permissions || {}; + rbacState.users = users.users || []; + rbacState.roles = roles.roles || []; + rbacState.assignments = assignments.assignments || []; + if (!rbacState.selectedUserId && rbacState.users.length) { + rbacState.selectedUserId = rbacState.users[0].id; + } + if (rbacState.selectedUserId && !rbacState.users.some(u => u.id === rbacState.selectedUserId)) { + rbacState.selectedUserId = rbacState.users[0] ? rbacState.users[0].id : ''; + } + renderRbac(); +} + +function selectedRbacUser() { + return rbacState.users.find(u => u.id === rbacState.selectedUserId) || null; +} + +function renderRbac() { + renderRbacOverview(); + renderRbacUsers(); + renderRbacRoles(); + renderRbacAssignments(); + renderRbacRoleCatalog(); + switchRbacView(rbacState.mainView); +} + +function switchRbacView(view) { + rbacState.mainView = view === 'roles' ? 'roles' : 'users'; + document.querySelectorAll('.rbac-workspace-tab').forEach(button => { + const active = button.dataset.rbacView === rbacState.mainView; + button.classList.toggle('is-active', active); + button.setAttribute('aria-selected', String(active)); + }); + const usersView = document.getElementById('rbac-view-users'); + const rolesView = document.getElementById('rbac-view-roles'); + if (usersView) usersView.hidden = rbacState.mainView !== 'users'; + if (rolesView) rolesView.hidden = rbacState.mainView !== 'roles'; +} + +function renderRbacOverview() { + const enabled = rbacState.users.filter(user => user.enabled).length; + const setText = (id, value) => { + const el = document.getElementById(id); + if (el) el.textContent = String(value); + }; + setText('rbac-metric-users', rbacState.users.length); + setText('rbac-metric-enabled', enabled); + setText('rbac-metric-roles', rbacState.roles.length); + setText('rbac-metric-assignments', rbacState.assignments.length); + const enabledMetric = document.querySelector('.rbac-metric-enabled'); + if (enabledMetric) { + const allEnabled = rbacState.users.length > 0 && enabled === rbacState.users.length; + enabledMetric.hidden = allEnabled; + } + const user = selectedRbacUser(); + const context = document.getElementById('rbac-metric-context'); + if (context) { + if (!user) { + context.hidden = true; + context.textContent = ''; + } else { + const access = resolveUserEffectiveAccess(user); + context.hidden = false; + context.textContent = rbacT('rbac.metricUserContext', '当前成员:{{name}} · {{roles}} 个角色 · {{grants}} 项授权', { + name: rbacUserDisplayName(user), + roles: access.userRoles.length, + grants: access.assignmentCount, + }); + } + } +} + +function renderRbacUsers() { + const list = document.getElementById('rbac-users-list'); + const count = document.getElementById('rbac-users-count'); + if (count) count.textContent = String(rbacState.users.length); + if (!list) return; + const keyword = rbacState.userSearch.trim().toLowerCase(); + const users = keyword + ? rbacState.users.filter(user => `${user.username || ''} ${rbacUserDisplayName(user)}`.toLowerCase().includes(keyword)) + : rbacState.users; + if (!users.length) { + list.innerHTML = `
${keyword ? rbacT('rbac.empty.noMatchingUsers', '没有匹配的成员') : rbacT('rbac.empty.noUsers', '还没有平台成员')}${keyword ? rbacT('rbac.empty.tryAnotherKeyword', '试试其他关键词') : rbacT('rbac.empty.addFirstUser', '点击右上角“添加成员”开始配置')}
`; + return; + } + list.innerHTML = users.map(user => { + const active = user.id === rbacState.selectedUserId ? ' is-active' : ''; + const roleNames = rbacUserRoles(user).map(roleId => { + const role = rbacState.roles.find(r => r.id === roleId); + return role ? rbacRoleName(role) : roleId; + }).join(' / '); + const assignmentCount = rbacState.assignments.filter(a => (a.userId || a.user_id) === user.id).length; + const summary = `${roleNames || rbacT('rbac.noRoleAssigned', '未分配角色')} · ${assignmentCount ? rbacT('rbac.resourceCount', '{{count}} 项资源', { count: assignmentCount }) : rbacT('rbac.noExplicitGrant', '无单独授权')}`; + const handle = `@${user.username}${rbacUserIsBuiltin(user) ? ` · ${rbacT('rbac.builtinAccount', '内置账号')}` : ''}`; + return ` + `; + }).join(''); +} + +function setRbacUserSearch(value) { + rbacState.userSearch = value || ''; + renderRbacUsers(); +} + +function selectRbacUser(userId) { + if (userId !== rbacState.selectedUserId && selectedUserRolesAreDirty() && + !confirm(rbacT('rbac.confirmDiscardSwitchUser', '当前角色变更尚未保存,确定放弃并切换成员吗?'))) return; + rbacState.selectedUserId = userId; + rbacState.pendingRoleUserId = ''; + rbacState.pendingUserRoles.clear(); + rbacState.selectedResourceIds.clear(); + rbacState.selectedResourceMeta.clear(); + rbacState.resourcePage = 0; + rbacState.assignmentPage = 0; + rbacState.showEffectivePermissions = false; + renderRbac(); + if (rbacState.activeTab === 'assignments') loadRbacResourceOptions(); +} + +function setRbacRoleSearch(value) { + rbacState.roleSearch = value || ''; + renderRbacRoleCatalog(); +} + +function rbacScopeInfo(scope) { + const meta = rbacScopeMeta[scope] || { tone: '' }; + return { + tone: meta.tone, + label: rbacT(`rbac.scopes.${scope}.label`, scope || rbacT('rbac.notConfigured', '未设置')), + hint: rbacT(`rbac.scopes.${scope}.hint`, ''), + }; +} + +function renderRbacRoleCatalog() { + const box = document.getElementById('rbac-role-catalog'); + if (!box) return; + const query = rbacState.roleSearch.trim().toLowerCase(); + const roles = rbacState.roles.filter(role => { + const permissionText = (role.permissions || []).join(' '); + return !query || `${rbacRoleName(role)} ${rbacRoleDescription(role)} ${permissionText}`.toLowerCase().includes(query); + }); + if (!roles.length) { + box.innerHTML = `
${rbacT('rbac.empty.noMatchingRoles', '没有匹配的角色')}${rbacT('rbac.empty.adjustRoleSearch', '调整搜索条件或新建自定义角色')}
`; + return; + } + box.innerHTML = roles.map(role => { + const permissions = role.permissions || []; + const modules = Array.from(new Set(permissions.map(key => key.split(':')[0]))).filter(Boolean); + const members = rbacState.users.filter(user => rbacUserRoles(user).includes(role.id)).length; + const scope = rbacScopeInfo(role.scope); + return `
+
+

${rbacEscape(rbacRoleName(role))}

${rbacEscape(rbacRoleDescription(role) || rbacT('rbac.noRoleDescription', '暂无角色说明'))}

+ ${rbacRoleIsSystem(role) ? `${rbacT('rbac.systemBuiltin', '系统内置')}` : `${rbacT('rbac.customRole', '自定义')}`} +
+
+
${permissions.length}${rbacT('rbac.operationPermissions', '操作权限')}
+
${members}${rbacT('rbac.assignedMembers', '已分配成员')}
+
${rbacEscape(scope.label)}${rbacT('rbac.scope', '资源范围')}
+
+
${modules.slice(0, 6).map(module => `${rbacEscape(rbacPermissionModuleLabel(module))}`).join('')}${modules.length > 6 ? `+${modules.length - 6}` : ''}
+
+ ${rbacEscape(scope.hint)} +
+ + ${rbacRoleIsSystem(role) ? '' : ``} +
+
+
`; + }).join(''); +} + +function renderRbacRoles() { + const title = document.getElementById('rbac-selected-title'); + const subtitle = document.getElementById('rbac-selected-subtitle'); + const summary = document.getElementById('rbac-selected-summary'); + const editBtn = document.getElementById('rbac-edit-user-btn'); + const deleteBtn = document.getElementById('rbac-delete-user-btn'); + const user = selectedRbacUser(); + ensurePendingUserRoles(user); + if (title) { + if (user) { + const status = user.enabled ? rbacT('rbac.statusEnabled', '启用') : rbacT('rbac.statusDisabled', '停用'); + const displayName = rbacUserDisplayName(user); + const handle = user.username === displayName ? '' : `@${user.username} · `; + title.innerHTML = `${rbacEscape(displayName)}${rbacEscape(handle)}${rbacEscape(status)}`; + } else { + title.textContent = rbacT('rbac.selectUser', '选择用户'); + } + } + if (subtitle) { + subtitle.textContent = user ? '' : rbacT('rbac.selectUserHint', '选择一个平台用户后编辑角色、状态与资源授权。'); + subtitle.hidden = !!user; + } + if (editBtn) editBtn.disabled = !user; + if (deleteBtn) deleteBtn.disabled = !user || rbacUserIsBuiltin(user); + renderRbacNoRoleNotice(user); + if (summary) { + if (!user) { + summary.innerHTML = ''; + } else { + const access = resolveUserEffectiveAccess(user); + const scope = rbacScopeInfo(access.effectiveScope); + const permissionsToggle = access.effectivePermissions.size + ? ` ` + : ''; + summary.innerHTML = ` +
${rbacT('rbac.roles', '角色')}${access.userRoles.length}
+
${rbacT('rbac.effectivePermissions', '有效权限')}${access.effectivePermissions.size}${permissionsToggle}
+
${rbacT('rbac.effectiveScope', '有效资源范围')}${rbacEscape(scope.label)}
+
${rbacT('rbac.metricAssignments', '资源授权')}${access.assignmentCount}
+ `; + } + } + renderRbacEffectivePermissions(user); + + const box = document.getElementById('rbac-roles-list'); + if (!box) return; + if (!rbacState.roles.length) { + box.innerHTML = `
${rbacT('rbac.empty.noRoles', '暂无角色')}
`; + return; + } + const userRoles = user ? rbacState.pendingUserRoles : new Set(); + box.innerHTML = rbacState.roles.map(role => { + const checked = userRoles.has(role.id); + const permissionCount = (role.permissions || []).length; + const assignedUsers = rbacState.users.filter(u => rbacUserRoles(u).includes(role.id)).length; + const scope = rbacScopeInfo(role.scope); + return ` +
+
+ + ${rbacEscape(scope.label)} +
+

${rbacEscape(rbacRoleDescription(role) || rbacT('rbac.noDescription', '无描述'))}

+
+ ${rbacT('rbac.permissionCount', '{{count}} 项权限', { count: permissionCount })} + ${rbacT('rbac.userCount', '{{count}} 个用户', { count: assignedUsers })} + ${rbacEscape(scope.hint)} +
+
`; + }).join(''); + syncRbacRoleSaveActions(); +} + +function ensurePendingUserRoles(user) { + if (!user) { + rbacState.pendingRoleUserId = ''; + rbacState.pendingUserRoles.clear(); + return; + } + if (rbacState.pendingRoleUserId !== user.id) { + rbacState.pendingRoleUserId = user.id; + rbacState.pendingUserRoles = new Set(rbacUserRoles(user)); + } +} + +function toggleSelectedUserRole(roleId, enabled) { + if (!selectedRbacUser()) return; + if (enabled) rbacState.pendingUserRoles.add(roleId); + else rbacState.pendingUserRoles.delete(roleId); + renderRbacRoles(); +} + +function selectedUserRolesAreDirty() { + const user = selectedRbacUser(); + if (!user) return false; + const current = new Set(rbacUserRoles(user)); + return current.size !== rbacState.pendingUserRoles.size || + Array.from(current).some(roleId => !rbacState.pendingUserRoles.has(roleId)); +} + +function syncRbacRoleSaveActions() { + const dirty = selectedUserRolesAreDirty(); + const indicator = document.getElementById('rbac-role-unsaved'); + const reset = document.getElementById('rbac-role-reset-btn'); + const apply = document.getElementById('rbac-role-apply-btn'); + if (indicator) indicator.hidden = !dirty; + if (reset) reset.disabled = !dirty; + if (apply) apply.disabled = !dirty; +} + +function resetSelectedUserRoles() { + const user = selectedRbacUser(); + rbacState.pendingUserRoles = new Set(rbacUserRoles(user)); + renderRbacRoles(); +} + +async function saveSelectedUserRoles() { + const user = selectedRbacUser(); + if (!user || !selectedUserRolesAreDirty()) return; + const roles = Array.from(rbacState.pendingUserRoles); + const saved = await rbacRun(async () => { + const res = await apiFetch(`/api/rbac/users/${encodeURIComponent(user.id)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + display_name: user.displayName || user.display_name || '', + enabled: user.enabled, + roles, + }), + }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || rbacT('rbac.errors.saveFailed', '保存失败')); + await refreshRbacUserRoles(user.id, roles); + rbacNotify(rbacT('rbac.messages.roleChangesSaved', '角色变更已保存,权限立即生效'), 'success'); + if (rbacState.activeTab === 'audit') loadRbacUserAuditLogs(); + }, rbacT('rbac.errors.saveFailed', '保存失败')); + if (!saved) { + renderRbacRoles(); + } +} + +function switchRbacTab(tab) { + if (tab !== rbacState.activeTab && selectedUserRolesAreDirty() && + !confirm(rbacT('rbac.confirmDiscardSwitchTab', '当前角色变更尚未保存,确定放弃并切换标签吗?'))) return; + if (tab !== rbacState.activeTab && selectedUserRolesAreDirty()) resetSelectedUserRoles(); + rbacState.activeTab = tab; + document.querySelectorAll('.rbac-tab').forEach(btn => btn.classList.toggle('is-active', btn.dataset.rbacTab === tab)); + const roles = document.getElementById('rbac-tab-roles'); + const assignments = document.getElementById('rbac-tab-assignments'); + const audit = document.getElementById('rbac-tab-audit'); + if (roles) roles.hidden = tab !== 'roles'; + if (assignments) assignments.hidden = tab !== 'assignments'; + if (audit) audit.hidden = tab !== 'audit'; + if (tab === 'assignments') loadRbacResourceOptions(); + if (tab === 'audit') loadRbacUserAuditLogs(); + initRbacSelects(); +} + +function focusRbacRoleAssignment() { + switchRbacTab('roles'); + requestAnimationFrame(() => { + const panel = document.getElementById('rbac-tab-roles'); + const list = document.getElementById('rbac-roles-list'); + const section = panel?.querySelector('.rbac-section-toolbar') || list; + if (panel) panel.scrollTop = 0; + if (section) section.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + const highlight = list || panel; + if (highlight) { + highlight.classList.remove('rbac-focus-pulse'); + void highlight.offsetWidth; + highlight.classList.add('rbac-focus-pulse'); + highlight.addEventListener('animationend', () => highlight.classList.remove('rbac-focus-pulse'), { once: true }); + } + const firstCheckbox = list?.querySelector('input[type="checkbox"]:not(:disabled)'); + if (firstCheckbox) firstCheckbox.focus({ preventScroll: true }); + }); +} + +function renderRbacAssignments() { + const box = document.getElementById('rbac-assignments-list'); + if (!box) return; + const user = selectedRbacUser(); + const rows = user ? rbacState.assignments.filter(a => a.userId === user.id || a.user_id === user.id) : []; + const count = document.getElementById('rbac-current-assignment-count'); + if (count) count.textContent = String(rows.length); + if (!user) { + box.innerHTML = `
${rbacT('rbac.empty.selectUserFirst', '请先选择用户')}
`; + renderRbacAssignmentPagination(0, 1); + renderRbacPendingSelection(); + return; + } + if (!rows.length) { + box.innerHTML = `
${rbacT('rbac.empty.noAssignments', '暂无资源授权')}${rbacT('rbac.empty.addAssignmentHint', '在左侧勾选资源,或使用上方「确认授权」按钮')}
`; + renderRbacAssignmentPagination(0, 1); + renderRbacPendingSelection(); + return; + } + const query = rbacState.assignmentSearch.trim().toLowerCase(); + const filteredRows = rows.filter(row => { + const type = row.resourceType || row.resource_type || ''; + const id = row.resourceId || row.resource_id || ''; + const label = row.resourceLabel || row.resource_label || ''; + const detail = row.resourceDetail || row.resource_detail || ''; + return (rbacState.assignmentType === 'all' || type === rbacState.assignmentType) && + (!query || `${rbacResourceLabel(type)} ${type} ${label} ${detail} ${id}`.toLowerCase().includes(query)); + }); + const totalPages = Math.max(1, Math.ceil(filteredRows.length / rbacState.assignmentPageSize)); + rbacState.assignmentPage = Math.min(rbacState.assignmentPage, totalPages - 1); + const start = rbacState.assignmentPage * rbacState.assignmentPageSize; + const visibleRows = filteredRows.slice(start, start + rbacState.assignmentPageSize); + if (!filteredRows.length) { + box.innerHTML = `
${rbacT('rbac.empty.noMatchingAssignments', '没有匹配的授权')}${rbacT('rbac.empty.adjustAssignmentFilter', '调整资源类型或搜索条件')}
`; + renderRbacAssignmentPagination(0, 1); + renderRbacPendingSelection(); + return; + } + box.innerHTML = visibleRows.map(row => { + const type = row.resourceType || row.resource_type; + const id = row.resourceId || row.resource_id; + const label = row.resourceLabel || row.resource_label || id; + const detail = rbacFormatResourceDetail(type, row.resourceDetail || row.resource_detail || ''); + return ` +
+ + ${rbacEscape(rbacResourceLabel(type))} + + ${rbacEscape(label)} + + ${rbacEscape(rbacShortId(id))} + ${detail ? `${rbacEscape(detail)}` : ''} + + + + + +
`; + }).join(''); + renderRbacAssignmentPagination(filteredRows.length, totalPages); + renderRbacPendingSelection(); +} + +function renderRbacAssignmentPagination(total, totalPages) { + const pagination = document.getElementById('rbac-assignment-pagination'); + if (!pagination) return; + const pages = Math.max(1, totalPages || 1); + rbacState.assignmentPage = Math.min(rbacState.assignmentPage, pages - 1); + const info = pagination.querySelector('[data-rbac-page-info]'); + const previous = pagination.querySelector('[data-rbac-page-previous]'); + const next = pagination.querySelector('[data-rbac-page-next]'); + if (info) { + info.textContent = total + ? rbacT('rbac.pagination.pageSummary', '第 {{page}} / {{pages}} 页 · 共 {{total}} 项', { + page: rbacState.assignmentPage + 1, + pages, + total, + }) + : rbacT('rbac.pagination.emptySummary', '共 0 项'); + } + if (previous) previous.disabled = total === 0 || rbacState.assignmentPage === 0; + if (next) next.disabled = total === 0 || rbacState.assignmentPage >= pages - 1; +} + +function filterRbacAssignments(value) { + rbacState.assignmentSearch = value || ''; + rbacState.assignmentPage = 0; + renderRbacAssignments(); +} + +function changeRbacAssignmentFilter(value) { + rbacState.assignmentType = value || 'all'; + rbacState.assignmentPage = 0; + renderRbacAssignments(); +} + +function changeRbacAssignmentPage(delta) { + rbacState.assignmentPage = Math.max(0, rbacState.assignmentPage + delta); + renderRbacAssignments(); +} + +function changeRbacResourceType() { + const typeInput = document.getElementById('rbac-assignment-type'); + if (!typeInput) return; + const nextType = typeInput.value; + const previousType = rbacState.assignmentResourceType || 'conversation'; + if (nextType === previousType) return; + if (rbacState.selectedResourceIds.size > 0 && + !confirm(rbacT('rbac.confirmDiscardResourceType', '切换资源类型将清空当前选择,确定继续吗?'))) { + typeInput.value = previousType; + initRbacSelects(); + return; + } + rbacState.assignmentResourceType = nextType; + rbacState.selectedResourceIds.clear(); + rbacState.selectedResourceMeta.clear(); + rbacState.resourcePage = 0; + const search = document.getElementById('rbac-resource-search'); + if (search) search.value = ''; + syncRbacAssignmentSubmit(); + loadRbacResourceOptions(); +} + +function queueRbacResourceSearch() { + clearTimeout(rbacState.resourceSearchTimer); + rbacState.resourcePage = 0; + rbacState.resourceSearchTimer = setTimeout(loadRbacResourceOptions, 250); +} + +async function loadRbacResourceOptions() { + const picker = document.getElementById('rbac-resource-picker'); + const typeInput = document.getElementById('rbac-assignment-type'); + if (!picker || !typeInput || !selectedRbacUser()) return; + const requestSeq = ++rbacState.resourceRequestSeq; + const query = document.getElementById('rbac-resource-search')?.value.trim() || ''; + picker.innerHTML = `
${rbacT('rbac.loadingResources', '正在加载真实资源…')}
`; + syncRbacResourcePagination(true); + try { + const offset = rbacState.resourcePage * rbacState.resourcePageSize; + const res = await apiFetch(`/api/rbac/resources?type=${encodeURIComponent(typeInput.value)}&q=${encodeURIComponent(query)}&limit=${rbacState.resourcePageSize}&offset=${offset}`); + const result = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(result.error || rbacT('rbac.errors.loadResourcesFailed', '加载资源失败')); + if (requestSeq !== rbacState.resourceRequestSeq) return; + rbacState.resourceOptions = result.resources || []; + rbacState.resourceHasMore = !!result.has_more; + renderRbacResourcePicker(); + } catch (error) { + if (requestSeq !== rbacState.resourceRequestSeq) return; + rbacState.resourceHasMore = false; + picker.innerHTML = `
${rbacEscape(error.message || rbacT('rbac.errors.loadResourcesFailed', '加载资源失败'))}
`; + syncRbacResourcePagination(false); + } +} + +function renderRbacResourcePicker() { + const picker = document.getElementById('rbac-resource-picker'); + if (!picker) return; + const user = selectedRbacUser(); + const resourceType = document.getElementById('rbac-assignment-type')?.value || ''; + const assigned = new Set(rbacState.assignments + .filter(row => (row.userId === user?.id || row.user_id === user?.id) && (row.resourceType || row.resource_type) === resourceType) + .map(row => row.resourceId || row.resource_id)); + if (!rbacState.resourceOptions.length) { + picker.innerHTML = `
${rbacT('rbac.empty.noMatchingResources', '没有匹配的真实资源')}
`; + syncRbacResourcePagination(false); + return; + } + picker.innerHTML = rbacState.resourceOptions.map(resource => { + const alreadyAssigned = assigned.has(resource.id); + const checked = rbacState.selectedResourceIds.has(resource.id); + const detail = rbacFormatResourceDetail(resourceType, resource.detail || ''); + return ``; + }).join(''); + syncRbacResourcePagination(false); +} + +function syncRbacResourcePagination(loading) { + const pagination = document.getElementById('rbac-resource-pagination'); + if (!pagination) return; + const previous = pagination.querySelector('[data-rbac-page-previous]'); + const next = pagination.querySelector('[data-rbac-page-next]'); + const info = pagination.querySelector('[data-rbac-page-info]'); + if (previous) previous.disabled = loading || rbacState.resourcePage === 0; + if (next) next.disabled = loading || !rbacState.resourceHasMore; + if (info) info.textContent = rbacT('rbac.pagination.resourcePage', '第 {{page}} 页', { page: rbacState.resourcePage + 1 }); +} + +function changeRbacResourcePage(delta) { + const nextPage = rbacState.resourcePage + delta; + if (nextPage < 0 || (delta > 0 && !rbacState.resourceHasMore)) return; + rbacState.resourcePage = nextPage; + loadRbacResourceOptions(); +} + +function toggleRbacResourceSelection(resourceId, checked) { + if (checked) { + rbacState.selectedResourceIds.add(resourceId); + const resource = rbacState.resourceOptions.find(item => item.id === resourceId); + rbacRememberSelectedResource(resource || { id: resourceId, label: resourceId, detail: '' }); + } else { + rbacState.selectedResourceIds.delete(resourceId); + rbacState.selectedResourceMeta.delete(resourceId); + if (rbacState.resourceOptions.length) renderRbacResourcePicker(); + } + syncRbacAssignmentSubmit(); +} + +function syncRbacAssignmentSubmit() { + renderRbacPendingSelection(); +} + +function rbacAuditActionLabel(log) { + const action = log && log.action ? String(log.action) : ''; + const key = `rbac.auditActions.${action}`; + const translated = rbacT(key, ''); + return translated || (log && log.message) || action; +} + +async function loadRbacUserAuditLogs() { + const box = document.getElementById('rbac-audit-list'); + const user = selectedRbacUser(); + if (!box) return; + if (!user) { + box.innerHTML = `
${rbacT('rbac.empty.selectUserFirst', '请先选择用户')}
`; + return; + } + rbacState.auditLoading = true; + box.innerHTML = `
${rbacT('common.loading', '加载中…')}
`; + try { + const params = new URLSearchParams({ + category: 'rbac', + q: user.id, + page: '1', + page_size: '20', + }); + const res = await apiFetch(`/api/audit/logs?${params.toString()}`); + const result = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(result.error || rbacT('rbac.errors.loadAuditFailed', '加载审计记录失败')); + rbacState.auditLogs = (result.logs || []).filter(log => { + const resourceId = log.resourceId || log.resource_id || ''; + const detail = log.detail || {}; + const detailUserId = detail.user_id || detail.userId || ''; + return resourceId === user.id || detailUserId === user.id || String(log.message || '').includes(user.username || ''); + }); + renderRbacAuditLogs(); + } catch (error) { + box.innerHTML = `
${rbacEscape(error.message || rbacT('rbac.errors.loadAuditFailed', '加载审计记录失败'))}
`; + } finally { + rbacState.auditLoading = false; + } +} + +function renderRbacAuditLogs() { + const box = document.getElementById('rbac-audit-list'); + if (!box) return; + if (!rbacState.auditLogs.length) { + box.innerHTML = `
${rbacT('rbac.empty.noAuditLogs', '暂无变更记录')}${rbacT('rbac.empty.noAuditLogsHint', '该成员的角色与授权变更会显示在这里')}
`; + return; + } + const formatTime = typeof formatAuditTime === 'function' + ? formatAuditTime + : (iso) => rbacText(iso); + box.innerHTML = rbacState.auditLogs.map(log => ` +
+ ${rbacEscape(rbacAuditActionLabel(log))} + ${rbacEscape(log.actor || rbacT('rbac.unknownActor', '未知操作者'))} + ${log.resourceType || log.resource_type ? `${rbacEscape(rbacResourceLabel(log.resourceType || log.resource_type))}` : ''} + ${log.resourceId || log.resource_id ? `${rbacEscape(rbacShortId(log.resourceId || log.resource_id))}` : ''} + ${rbacEscape(formatTime(log.createdAt || log.created_at))} +
`).join(''); +} + +function openRbacUserModal(userId = '') { + const user = userId ? rbacState.users.find(u => u.id === userId) : null; + document.getElementById('rbac-user-id').value = user ? user.id : ''; + document.getElementById('rbac-username').value = user ? user.username : ''; + document.getElementById('rbac-username').disabled = !!user; + document.getElementById('rbac-display-name').value = user ? (user.displayName || user.display_name || '') : ''; + document.getElementById('rbac-password').value = ''; + document.getElementById('rbac-user-enabled').checked = user ? !!user.enabled : true; + const createRoles = document.getElementById('rbac-user-roles-create-section'); + const editRoles = document.getElementById('rbac-user-roles-edit-section'); + if (createRoles) createRoles.hidden = !!user; + if (editRoles) editRoles.hidden = !user; + if (user) renderRbacUserRolesReadonly(user); + else renderRbacUserRoleCheckboxes(new Set()); + initRbacSelects(); + openAppModal('rbac-user-modal'); +} + +function renderRbacUserRolesReadonly(user) { + const box = document.getElementById('rbac-user-roles-readonly'); + if (!box) return; + const roles = rbacUserRoles(user).map(roleId => rbacState.roles.find(role => role.id === roleId)).filter(Boolean); + if (!roles.length) { + box.innerHTML = `
${rbacT('rbac.noRoleAssigned', '未分配角色')}
`; + return; + } + box.innerHTML = roles.map(role => { + const scope = rbacScopeInfo(role.scope); + return `
${rbacEscape(rbacRoleName(role))}${rbacEscape(scope.label)}
`; + }).join(''); +} + +function openRbacRolesFromUserModal() { + closeRbacUserModal(); + focusRbacRoleAssignment(); +} + +function openRbacUserModalForSelected() { + const user = selectedRbacUser(); + if (!user) return; + if (selectedUserRolesAreDirty() && !confirm(rbacT('rbac.confirmDiscardEditUser', '当前角色变更尚未保存,确定放弃并编辑成员资料吗?'))) return; + if (selectedUserRolesAreDirty()) resetSelectedUserRoles(); + openRbacUserModal(user.id); +} + +function closeRbacUserModal() { + closeAppModal('rbac-user-modal'); +} + +function renderRbacUserRoleCheckboxes(selected) { + const box = document.getElementById('rbac-user-role-checkboxes'); + if (!box) return; + box.innerHTML = rbacState.roles.map(role => { + const scope = rbacScopeInfo(role.scope); + return ` + `; + }).join(''); +} + +async function saveRbacUser() { + await rbacRun(async () => { + const id = document.getElementById('rbac-user-id').value; + const isEdit = !!id; + const payload = { + display_name: document.getElementById('rbac-display-name').value.trim(), + password: document.getElementById('rbac-password').value, + enabled: document.getElementById('rbac-user-enabled').checked, + }; + if (!isEdit) { + payload.roles = Array.from(document.querySelectorAll('#rbac-user-role-checkboxes input:checked')).map(el => el.value); + payload.username = document.getElementById('rbac-username').value.trim(); + } + let url = '/api/rbac/users'; + let method = 'POST'; + if (isEdit) { + url += '/' + encodeURIComponent(id); + method = 'PUT'; + if (!payload.password) delete payload.password; + } + const res = await apiFetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || rbacT('rbac.errors.saveFailed', '保存失败')); + closeRbacUserModal(); + await loadPlatformRbac(); + if (isEdit && rbacState.activeTab === 'audit') loadRbacUserAuditLogs(); + }, rbacT('rbac.errors.saveUserFailed', '保存用户失败')); +} + +async function deleteSelectedRbacUser() { + await rbacRun(async () => { + const user = selectedRbacUser(); + if (!user) return; + if (rbacUserIsBuiltin(user)) { + rbacNotify(rbacT('rbac.deleteBuiltinUser', '内置管理员不能删除'), 'error'); + return; + } + const access = resolveUserEffectiveAccess(user); + const confirmMessage = rbacT('rbac.deleteUserConfirmDetailed', '确认删除成员 {{name}}(@{{username}})?\n\n将立即吊销其会话,并清除 {{roles}} 个角色绑定与 {{grants}} 项资源授权。', { + name: rbacUserDisplayName(user), + username: user.username, + roles: access.userRoles.length, + grants: access.assignmentCount, + }); + if (!confirm(confirmMessage)) return; + const res = await apiFetch(`/api/rbac/users/${encodeURIComponent(user.id)}`, { method: 'DELETE' }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || rbacT('rbac.errors.deleteFailed', '删除失败')); + rbacState.selectedUserId = ''; + await loadPlatformRbac(); + }, rbacT('rbac.errors.deleteUserFailed', '删除用户失败')); +} + +function openRbacRoleModal(roleId = '') { + const role = roleId ? rbacState.roles.find(r => r.id === roleId) : null; + document.getElementById('rbac-role-id').value = role ? role.id : ''; + document.getElementById('rbac-role-name').value = role ? rbacRoleName(role) : ''; + document.getElementById('rbac-role-description').value = role ? rbacRoleDescription(role) : ''; + document.getElementById('rbac-role-scope').value = role ? role.scope : 'assigned'; + rbacState.editingRoleIsSystem = rbacRoleIsSystem(role); + ['rbac-role-name', 'rbac-role-description', 'rbac-role-scope'].forEach(id => { + const field = document.getElementById(id); + if (field) field.disabled = rbacState.editingRoleIsSystem; + }); + const saveButton = document.getElementById('rbac-role-save-btn'); + if (saveButton) saveButton.hidden = rbacState.editingRoleIsSystem; + document.querySelectorAll('.rbac-permission-tools button').forEach(button => { + button.disabled = rbacState.editingRoleIsSystem; + }); + const title = document.getElementById('rbac-role-modal-title'); + if (title) title.textContent = rbacState.editingRoleIsSystem + ? rbacT('rbac.viewSystemRole', '查看系统角色') + : (role ? rbacT('rbac.editRole', '编辑角色') : rbacT('rbac.createRole', '新建角色')); + const search = document.getElementById('rbac-permission-search'); + if (search) search.value = ''; + rbacState.permissionSearch = ''; + rbacState.rolePermissionSelection = new Set((role && role.permissions) || []); + renderRbacPermissionCheckboxes(); + initRbacSelects(); + openAppModal('rbac-role-modal'); +} + +function closeRbacRoleModal() { + closeAppModal('rbac-role-modal'); +} + +function renderRbacPermissionCheckboxes() { + const box = document.getElementById('rbac-permission-checkboxes'); + if (!box) return; + const grouped = {}; + const query = rbacState.permissionSearch.trim().toLowerCase(); + Object.keys(rbacState.permissions).sort().forEach(key => { + const desc = rbacPermissionLabel(key); + if (query && !`${key} ${desc}`.toLowerCase().includes(query)) return; + const module = key.split(':')[0] || 'other'; + if (!grouped[module]) grouped[module] = []; + grouped[module].push(key); + }); + if (!Object.keys(grouped).length) { + box.innerHTML = `
${rbacT('rbac.empty.noMatchingPermissions', '没有匹配的权限')}
`; + return; + } + box.innerHTML = Object.keys(grouped).sort().map(module => ` +
+
+

${rbacEscape(rbacPermissionModuleLabel(module))}${rbacEscape(module)}

+ ${grouped[module].length} +
+
+ ${grouped[module].map(key => ` + `).join('')} +
+
`).join(''); +} + +function setRbacPermissionSelected(key, checked) { + if (checked) rbacState.rolePermissionSelection.add(key); + else rbacState.rolePermissionSelection.delete(key); +} + +function filterRbacPermissions(value) { + rbacState.permissionSearch = value || ''; + renderRbacPermissionCheckboxes(); +} + +function selectVisibleRbacPermissions(checked) { + document.querySelectorAll('#rbac-permission-checkboxes input[type="checkbox"]').forEach(input => { + input.checked = checked; + setRbacPermissionSelected(input.value, checked); + }); +} + +async function saveRbacRole() { + await rbacRun(async () => { + const id = document.getElementById('rbac-role-id').value; + const payload = { + name: document.getElementById('rbac-role-name').value.trim(), + description: document.getElementById('rbac-role-description').value.trim(), + scope: document.getElementById('rbac-role-scope').value, + permissions: Array.from(rbacState.rolePermissionSelection), + }; + const url = id ? `/api/rbac/roles/${encodeURIComponent(id)}` : '/api/rbac/roles'; + const res = await apiFetch(url, { + method: id ? 'PUT' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || rbacT('rbac.errors.saveFailed', '保存失败')); + closeRbacRoleModal(); + await loadPlatformRbac(); + }, rbacT('rbac.errors.saveRoleFailed', '保存角色失败')); +} + +async function deleteRbacRole(roleId) { + await rbacRun(async () => { + if (!confirm(rbacT('rbac.deleteRoleConfirm', '确认删除该平台角色?'))) return; + const res = await apiFetch(`/api/rbac/roles/${encodeURIComponent(roleId)}`, { method: 'DELETE' }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || rbacT('rbac.errors.deleteFailed', '删除失败')); + await loadPlatformRbac(); + }, rbacT('rbac.errors.deleteRoleFailed', '删除角色失败')); +} + +async function createRbacAssignment() { + await rbacRun(async () => { + const user = selectedRbacUser(); + if (!user) return; + const resourceType = document.getElementById('rbac-assignment-type').value; + const manualIds = document.getElementById('rbac-assignment-id').value.split(/[\s,,;;]+/).map(id => id.trim()).filter(Boolean); + const resourceIds = Array.from(new Set([...rbacState.selectedResourceIds, ...manualIds])); + if (!resourceIds.length) { + rbacNotify(rbacT('rbac.errors.enterResourceId', '请输入至少一个资源 ID'), 'error'); + return; + } + if (resourceIds.length > 100) { + rbacNotify(rbacT('rbac.errors.tooManyResources', '一次最多授权 100 个资源'), 'error'); + return; + } + const res = await apiFetch('/api/rbac/resource-assignments', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: user.id, resource_type: resourceType, resource_ids: resourceIds }), + }); + const result = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(result.error || rbacT('rbac.errors.batchGrantFailed', '批量授权失败')); + document.getElementById('rbac-assignment-id').value = ''; + rbacState.selectedResourceIds.clear(); + rbacState.selectedResourceMeta.clear(); + await refreshRbacAssignments(); + await loadRbacResourceOptions(); + syncRbacAssignmentSubmit(); + if (rbacState.activeTab === 'audit') loadRbacUserAuditLogs(); + const created = Number(result.created || 0); + const skipped = Number(result.skipped || 0); + rbacNotify(skipped + ? rbacT('rbac.messages.grantPartial', '新增 {{created}} 项授权,{{skipped}} 项已存在或重复', { created, skipped }) + : rbacT('rbac.messages.grantSuccess', '已授权 {{count}} 项{{resource}}', { count: created, resource: rbacResourceLabel(resourceType) }), 'success'); + }, rbacT('rbac.errors.grantFailed', '授权失败')); +} + +async function deleteRbacAssignment(id) { + await rbacRun(async () => { + const res = await apiFetch(`/api/rbac/resource-assignments/${encodeURIComponent(id)}`, { method: 'DELETE' }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || rbacT('rbac.errors.revokeFailed', '撤销失败')); + await refreshRbacAssignments(); + if (rbacState.activeTab === 'assignments') await loadRbacResourceOptions(); + if (rbacState.activeTab === 'audit') loadRbacUserAuditLogs(); + }, rbacT('rbac.errors.revokeFailed', '撤销失败')); +} + +document.addEventListener('languagechange', () => { + renderRbac(); + if (rbacState.resourceOptions.length) renderRbacResourcePicker(); + renderRbacPermissionCheckboxes(); + renderRbacEffectivePermissions(selectedRbacUser()); + if (rbacState.auditLogs.length) renderRbacAuditLogs(); + initRbacSelects(); + const roleId = document.getElementById('rbac-role-id')?.value; + const role = roleId ? rbacState.roles.find(item => item.id === roleId) : null; + if (role && rbacRoleIsSystem(role)) { + const name = document.getElementById('rbac-role-name'); + const description = document.getElementById('rbac-role-description'); + const title = document.getElementById('rbac-role-modal-title'); + if (name) name.value = rbacRoleName(role); + if (description) description.value = rbacRoleDescription(role); + if (title) title.textContent = rbacT('rbac.viewSystemRole', '查看系统角色'); + } +}); + +window.openRbacRolesFromUserModal = openRbacRolesFromUserModal; +window.focusRbacRoleAssignment = focusRbacRoleAssignment; +window.toggleRbacEffectivePermissions = toggleRbacEffectivePermissions; +window.loadRbacUserAuditLogs = loadRbacUserAuditLogs; +window.clearRbacResourceSelection = clearRbacResourceSelection; +window.rbacCopyResourceId = rbacCopyResourceId; +window.initPlatformRbacPage = initPlatformRbacPage; +window.initRbacSelects = initRbacSelects; +window.loadPlatformRbac = loadPlatformRbac; +window.selectRbacUser = selectRbacUser; +window.switchRbacView = switchRbacView; +window.setRbacRoleSearch = setRbacRoleSearch; +window.setRbacUserSearch = setRbacUserSearch; +window.switchRbacTab = switchRbacTab; +window.openRbacUserModal = openRbacUserModal; +window.openRbacUserModalForSelected = openRbacUserModalForSelected; +window.closeRbacUserModal = closeRbacUserModal; +window.saveRbacUser = saveRbacUser; +window.deleteSelectedRbacUser = deleteSelectedRbacUser; +window.openRbacRoleModal = openRbacRoleModal; +window.closeRbacRoleModal = closeRbacRoleModal; +window.saveRbacRole = saveRbacRole; +window.deleteRbacRole = deleteRbacRole; +window.filterRbacPermissions = filterRbacPermissions; +window.selectVisibleRbacPermissions = selectVisibleRbacPermissions; +window.setRbacPermissionSelected = setRbacPermissionSelected; +window.createRbacAssignment = createRbacAssignment; +window.deleteRbacAssignment = deleteRbacAssignment; +window.changeRbacResourceType = changeRbacResourceType; +window.queueRbacResourceSearch = queueRbacResourceSearch; +window.loadRbacResourceOptions = loadRbacResourceOptions; +window.toggleRbacResourceSelection = toggleRbacResourceSelection; +window.changeRbacResourcePage = changeRbacResourcePage; +window.filterRbacAssignments = filterRbacAssignments; +window.changeRbacAssignmentFilter = changeRbacAssignmentFilter; +window.changeRbacAssignmentPage = changeRbacAssignmentPage; +window.syncRbacAssignmentSubmit = syncRbacAssignmentSubmit; +window.resetSelectedUserRoles = resetSelectedUserRoles; +window.saveSelectedUserRoles = saveSelectedUserRoles; diff --git a/web/static/js/router.js b/web/static/js/router.js index 76d9a154..fa9248b4 100644 --- a/web/static/js/router.js +++ b/web/static/js/router.js @@ -81,7 +81,7 @@ function initRouter() { const hashParts = hash.split('?'); let pageId = hashParts[0]; if (pageId === 'c2') pageId = 'c2-listeners'; - if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { + if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { switchPage(pageId); if (pageId === 'chat') { scheduleChatConversationFromHash(500); @@ -472,6 +472,11 @@ async function initPage(pageId) { }); } break; + case 'platform-rbac': + if (typeof initPlatformRbacPage === 'function') { + initPlatformRbacPage(); + } + break; case 'workflows': if (typeof refreshWorkflows === 'function') { refreshWorkflows(); @@ -538,7 +543,7 @@ document.addEventListener('DOMContentLoaded', function() { let pageId = hashParts[0]; if (pageId === 'c2') pageId = 'c2-listeners'; - if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { + if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { switchPage(pageId); if (pageId === 'chat') { scheduleChatConversationFromHash(200); diff --git a/web/static/js/settings.js b/web/static/js/settings.js index 6f4d91e2..7b2b8e0d 100644 --- a/web/static/js/settings.js +++ b/web/static/js/settings.js @@ -509,6 +509,13 @@ function syncC2NavFromConfig(cfg) { // 切换设置分类 function switchSettingsSection(section) { + if (section === 'rbac') { + if (typeof switchPage === 'function') { + switchPage('platform-rbac'); + } + return; + } + // 更新导航项状态 document.querySelectorAll('.settings-nav-item').forEach(item => { item.classList.remove('active'); @@ -3660,3 +3667,6 @@ document.addEventListener('languagechange', function () { console.warn('languagechange MCP refresh failed', e); } }); + +window.initSettingsCustomSelects = initSettingsCustomSelects; +window.refreshSettingsCustomSelects = refreshSettingsCustomSelects; diff --git a/web/static/js/webshell.js b/web/static/js/webshell.js index 7c6895a2..8992529f 100644 --- a/web/static/js/webshell.js +++ b/web/static/js/webshell.js @@ -1048,6 +1048,7 @@ function probeWebshellConnection(conn) { cmd_param: conn.cmdParam || '', encoding: webshellConnEncoding(conn), os: webshellConnOS(conn), + connection_id: conn.id || '', command: buildWebshellProbeCommand(probeToken) }) }) @@ -3854,6 +3855,7 @@ function execWebshellCommand(conn, command) { cmd_param: conn.cmdParam || '', encoding: webshellConnEncoding(conn), os: webshellConnOS(conn), + connection_id: conn.id || '', command: command }) }).then(function (r) { return r.json(); }) diff --git a/web/templates/index.html b/web/templates/index.html index d9c48c1b..df187bd3 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -39,9 +39,13 @@ CyberStrikeAI -

请输入配置中的访问密码

+

使用平台账号登录,默认管理员为 admin。

+
+ + +
@@ -123,14 +127,29 @@
- - +
+ + +
+ +
+
+
+
+ 0 + 个账号 +
+
+ 0 + 个启用账号 +
+
+ 0 + 个角色 +
+
+ 0 + 项授权 +
+
+ +
+ + +
+
+
+
+
+
+

用户

+

按账号选择后管理角色与资源范围。

+
+ 0 +
+ +
+
加载中...
+
+
+
+
+
+

选择用户

+

选择一个平台用户后编辑角色、状态与资源授权。

+
+
+ + +
+
+
+ + +
+ + + +
+
+
+
+ 角色成员关系 +

角色决定能做什么,资源范围决定能看到哪些数据。

+
+
+ + + +
+
+
+
+ + +
+
+ +
+
@@ -5231,6 +5476,93 @@ + + + +