diff --git a/docs/bug-sheet-review.md b/docs/bug-sheet-review.md index ee3318ee..bd4e0556 100644 --- a/docs/bug-sheet-review.md +++ b/docs/bug-sheet-review.md @@ -68,3 +68,9 @@ - Excel 第 11 行:当前 HTML 的 `fact-modal-summary` 标注“摘要(索引一行)”,`saveFactModal` 读取 summary 并必填检查,已存在原描述称缺失的字段。第 16 行是跨项目去重建议,当前 `list_vulnerabilities` 按授权项目/会话列举以避免重复,不新增跨项目合并。第 17 行原记录已注明没有点击保存;当前 tool-guard 草稿变更有未保存标识,只有保存动作 PUT 才改变服务端配置。 - Excel 第 57 行:执行当前 `resetAuditLogFilters` 与查询参数构建函数,验证关键词、类别、动作、结果、时间条件全部清空,页码回到 1,模板按钮调用此函数。原描述在当前实现未复现。 - 验证:新增工作流保存/API 校验、纯文本与 HITL 实际运行、实际工具参数传递、列表标记和审计重置回归;后端完整测试及新增前端测试在提交前执行。 + +## 追加:密码表单反馈与传输核对 + +- Excel 第 3、6 行:登录空密码通过自定义校验展示原因并聚焦字段,编辑后清除旧错误;显示/隐藏密码按钮在关闭或重新打开登录时恢复隐藏。修改密码显示当前密码必填、新密码长度与确认不一致的具体原因,服务端失败也保留在表单中。 +- 四项真实脚本回归通过;隔离预览使用实际模板片段与样式,在 375px 登录布局和桌面密码表单中验证提示、焦点和按钮。预览未使用真实账号凭据。 +- Excel 第 4 行:HTTP 下请求体可被截获,属于部署传输配置风险。当前服务已有 TLS、HTTP 到 HTTPS 重定向和同端口协议分流;现有 TLS/重定向测试通过。应在部署中启用 HTTPS,未加入额外客户端加密协议或改动凭据配置。 diff --git a/docs/bug-sheet-status.md b/docs/bug-sheet-status.md index 11bd9cec..440eacff 100644 --- a/docs/bug-sheet-status.md +++ b/docs/bug-sheet-status.md @@ -5,10 +5,10 @@ | Excel 行 | 序号 | 问题摘要 | 状态 | | --- | --- | --- | --- | | 2 | 1 | 对话页面显示错乱了,按钮和文字位置全乱了, | 待核对 | -| 3 | 2 | 登录时没填密码也能提交,但提示信息和样式可以在优化下 | 待核对 | -| 4 | 3 | 登录密码是明文传输的,容易被截获有安全风险,建议参考TDP的加密方案处理 | 待核对 | +| 3 | 2 | 登录时没填密码也能提交,但提示信息和样式可以在优化下 | 已修复;空值反馈、字段原因及浏览器布局验证通过 | +| 4 | 3 | 登录密码是明文传输的,容易被截获有安全风险,建议参考TDP的加密方案处理 | HTTP 部署存在传输风险;现有 HTTPS 与 HTTP 重定向已实现并通过测试,需启用 TLS | | 5 | 4 | 登录页用户名字段未做必填校验,提交时 username 字段值为空字符串,应增加非空验证 | 已修复并有针对性验证 | -| 6 | 5 | 修改密码,只有红框提示,没有具体错误原因 | 待核对 | +| 6 | 5 | 修改密码,只有红框提示,没有具体错误原因 | 已修复;空值反馈、字段原因及浏览器布局验证通过 | | 7 | 6 | 通知,点击查看后跳转到页面,通知的计数还在,只有点了已读在不在。 | 已修复并有针对性验证 | | 8 | 7 | 新增资产,自动识别出来的不是中文呢,好像是时区的问题。 | 待核对 | | 9 | 8 | 接上一个,改成中国后,省份还是拼音 | 待核对 | diff --git a/web/static/css/style.css b/web/static/css/style.css index 415399a8..775cc77d 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -6550,6 +6550,33 @@ html[data-theme="dark"] .chat-input-container .send-btn:disabled::before { margin-bottom: 0; } +.login-password-field { + display: flex; + align-items: center; + gap: 8px; +} + +.login-password-field input { + min-width: 0; + flex: 1; +} + +.login-password-field button { + flex: 0 0 auto; + white-space: nowrap; +} + +.password-field-error { + color: var(--error-color, #dc3545); + margin-top: 6px; + font-size: 0.8125rem; +} + +.login-form input[aria-invalid="true"], +.login-form input[aria-invalid="true"]:focus { + border-color: var(--error-color, #dc3545); +} + .login-form .form-group label { display: block; margin-bottom: 8px; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 1fdc9670..528a783f 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -68,6 +68,8 @@ "itemDefaultTitle": "Notification" }, "login": { + "showPassword": "Show password", + "hidePassword": "Hide password", "title": "Sign in to CyberStrikeAI", "titlePrefix": "Sign in to", "subtitle": "Sign in with a platform account. The default administrator is admin.", @@ -3342,6 +3344,9 @@ "detailSession": "Session" }, "settingsSecurity": { + "currentRequired": "Enter your current password", + "newTooShort": "The new password must contain at least 8 characters", + "confirmMismatch": "The new passwords do not match", "changePasswordTitle": "Change password", "changePasswordDesc": "After changing password, sign in again with the new password.", "currentPassword": "Current password", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index a024bc19..e3139af5 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -68,6 +68,8 @@ "itemDefaultTitle": "Уведомление" }, "login": { + "showPassword": "Показать пароль", + "hidePassword": "Скрыть пароль", "title": "Вход в CyberStrikeAI", "titlePrefix": "Вход в", "subtitle": "Войдите с учётной записью платформы. Администратор по умолчанию — admin.", @@ -3355,6 +3357,9 @@ "detailSession": "Сессия" }, "settingsSecurity": { + "currentRequired": "Введите текущий пароль", + "newTooShort": "Новый пароль должен содержать не менее 8 символов", + "confirmMismatch": "Новые пароли не совпадают", "changePasswordTitle": "Сменить пароль", "changePasswordDesc": "После смены пароля войдите снова с новым паролем.", "currentPassword": "Текущий пароль", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index fd0af483..af096259 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -68,6 +68,8 @@ "itemDefaultTitle": "通知" }, "login": { + "showPassword": "显示密码", + "hidePassword": "隐藏密码", "title": "登录 CyberStrikeAI", "titlePrefix": "登录", "subtitle": "使用平台账号登录,默认管理员为 admin。", @@ -3330,6 +3332,9 @@ "detailSession": "会话" }, "settingsSecurity": { + "currentRequired": "请输入当前密码", + "newTooShort": "新密码至少需要 8 位", + "confirmMismatch": "两次输入的新密码不一致", "changePasswordTitle": "修改密码", "changePasswordDesc": "修改登录密码后,需要使用新密码重新登录。", "currentPassword": "当前密码", diff --git a/web/static/js/auth.js b/web/static/js/auth.js index ee8dfb59..5298232c 100644 --- a/web/static/js/auth.js +++ b/web/static/js/auth.js @@ -92,7 +92,25 @@ function resolveAuthPromises(success) { authPromise = null; } +function setLoginPasswordVisible(visible) { + const input = document.getElementById('login-password'); + const button = document.getElementById('login-password-toggle'); + if (input) input.type = visible ? 'text' : 'password'; + if (button) { + button.setAttribute('aria-pressed', String(visible)); + const key = visible ? 'login.hidePassword' : 'login.showPassword'; + button.setAttribute('data-i18n', key); + button.textContent = typeof window.t === 'function' ? window.t(key) : (visible ? '隐藏密码' : '显示密码'); + } +} + +function toggleLoginPasswordVisibility() { + const input = document.getElementById('login-password'); + setLoginPasswordVisible(input && input.type === 'password'); +} + function showLoginOverlay(message = '') { + setLoginPasswordVisible(false); const overlay = document.getElementById('login-overlay'); const errorBox = document.getElementById('login-error'); const usernameInput = document.getElementById('login-username'); @@ -120,6 +138,7 @@ function showLoginOverlay(message = '') { } function hideLoginOverlay() { + setLoginPasswordVisible(false); const overlay = document.getElementById('login-overlay'); const errorBox = document.getElementById('login-error'); const usernameInput = document.getElementById('login-username'); @@ -131,7 +150,9 @@ function hideLoginOverlay() { } if (passwordInput) { passwordInput.value = ''; + passwordInput.setAttribute('aria-invalid', 'false'); } + if (usernameInput) usernameInput.setAttribute('aria-invalid', 'false'); if (usernameInput && !authUser) { usernameInput.value = ''; } @@ -244,6 +265,14 @@ async function apiUploadWithProgress(url, formData, options = {}) { }); } +function clearLoginFormError(event) { + const input = event.target; + if (!input || (input.id !== 'login-username' && input.id !== 'login-password')) return; + input.setAttribute('aria-invalid', 'false'); + const error = document.getElementById('login-error'); + if (error) { error.textContent = ''; error.style.display = 'none'; } +} + async function submitLogin(event) { event.preventDefault(); const usernameInput = document.getElementById('login-username'); @@ -251,6 +280,8 @@ async function submitLogin(event) { const errorBox = document.getElementById('login-error'); const submitBtn = document.querySelector('.login-submit'); + [usernameInput, passwordInput].forEach(input => input && input.setAttribute('aria-invalid', 'false')); + if (!passwordInput) { return; } @@ -258,6 +289,7 @@ async function submitLogin(event) { const username = usernameInput ? usernameInput.value.trim() : ''; const password = passwordInput.value.trim(); if (!username || Array.from(username).length > 64) { + if (usernameInput) usernameInput.setAttribute('aria-invalid', 'true'); if (errorBox) { errorBox.textContent = typeof window.t === 'function' ? window.t('auth.usernameRequired') : '用户名不能为空,且不能超过 64 个字符'; errorBox.style.display = 'block'; @@ -266,6 +298,7 @@ async function submitLogin(event) { return; } if (!password) { + passwordInput.setAttribute('aria-invalid', 'true'); if (errorBox) { const msgEmpty = (typeof window !== 'undefined' && typeof window.t === 'function') ? window.t('auth.enterPassword') @@ -273,6 +306,7 @@ async function submitLogin(event) { errorBox.textContent = msgEmpty; errorBox.style.display = 'block'; } + passwordInput.focus(); return; } @@ -683,6 +717,7 @@ function setupLoginUI() { const loginForm = document.getElementById('login-form'); if (loginForm) { loginForm.addEventListener('submit', submitLogin); + loginForm.addEventListener('input', clearLoginFormError); } } @@ -959,6 +994,7 @@ window.generateRobotBindingCode = generateRobotBindingCode; window.copyRobotBindingCode = copyRobotBindingCode; window.deleteRobotAccountBinding = deleteRobotAccountBinding; window.logout = logout; +window.toggleLoginPasswordVisibility = toggleLoginPasswordVisibility; window.hasPermission = hasPermission; window.hasAnyPermission = hasAnyPermission; window.readApiError = readApiError; diff --git a/web/static/js/password-feedback.test.cjs b/web/static/js/password-feedback.test.cjs new file mode 100644 index 00000000..a3a59fac --- /dev/null +++ b/web/static/js/password-feedback.test.cjs @@ -0,0 +1,71 @@ +const fs=require('node:fs'); +const vm=require('node:vm'); +const test=require('node:test'); +const assert=require('node:assert/strict'); +const auth=fs.readFileSync('web/static/js/auth.js','utf8'); +const settings=fs.readFileSync('web/static/js/settings.js','utf8'); +function input(id,value='') { + const attrs={}; + return {id,value,type:'password',style:{},classList:{toggle(){},remove(){}},focus(){this.focused=true;},setAttribute(k,v){attrs[k]=v;},getAttribute:k => attrs[k]}; +} +function harness(values={}) { + const fields={}; + for (const id of ['auth-current-password','auth-new-password','auth-confirm-password','login-username','login-password']) fields[id]=input(id,values[id]||''); + for (const id of ['auth-current-password-error','auth-new-password-error','auth-confirm-password-error','password-form-error','login-error','login-password-toggle']) fields[id]=input(id); + const button={disabled:false};let calls=0; + const ctx={document:{getElementById:id => fields[id]||null,querySelector:() => button},window:{t:x => x},alert(){},console:{error(){}}, + fetch:async () => {calls++;throw Error('unexpected login request');}, + apiFetch:async () => {calls++;return {ok:false,json:async () => ({error:'test-only incorrect current password'})};}}; + vm.createContext(ctx); + let start=auth.indexOf('function setLoginPasswordVisible('); + vm.runInContext(auth.slice(start,auth.indexOf('function showLoginOverlay(',start)),ctx); + start=auth.indexOf('function clearLoginFormError('); + vm.runInContext(auth.slice(start,auth.indexOf('\nfunction ',start)),ctx); + start=settings.indexOf('function setPasswordFieldError('); + vm.runInContext(settings.slice(start,settings.indexOf('// ==================== 外部MCP管理',start)),ctx); + return {ctx,fields,button,calls:() => calls}; +} +test('empty login password explains the error and never calls the server',async () => { + const h=harness({'login-username':'admin'}); + await h.ctx.submitLogin({preventDefault(){}}); + assert.equal(h.calls(),0); + assert.equal(h.fields['login-error'].textContent,'auth.enterPassword'); + assert.equal(h.fields['login-password'].getAttribute('aria-invalid'),'true'); + assert.equal(h.fields['login-password'].focused,true); + h.ctx.clearLoginFormError({target:h.fields['login-password']}); + assert.equal(h.fields['login-error'].style.display,'none'); + assert.equal(h.fields['login-password'].getAttribute('aria-invalid'),'false'); + assert.match(fs.readFileSync('web/templates/index.html','utf8'),/