From 05d7d9ec65072a29bdd8acef8c49d00053900873 Mon Sep 17 00:00:00 2001 From: Ed1s0nZ Date: Tue, 6 Oct 2026 02:49:17 +0800 Subject: [PATCH] fix: explain password validation errors and support login preview --- docs/bug-sheet-review.md | 6 ++ docs/bug-sheet-status.md | 6 +- web/static/css/style.css | 27 +++++++++ web/static/i18n/en-US.json | 5 ++ web/static/i18n/ru-RU.json | 5 ++ web/static/i18n/zh-CN.json | 5 ++ web/static/js/auth.js | 36 ++++++++++++ web/static/js/password-feedback.test.cjs | 71 ++++++++++++++++++++++++ web/static/js/settings.js | 39 +++++++++++-- web/templates/index.html | 17 ++++-- 10 files changed, 203 insertions(+), 14 deletions(-) create mode 100644 web/static/js/password-feedback.test.cjs 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'),/
]*novalidate/); +}); +test('password preview toggles accessibly and can be reset without changing the value',() => { + const h=harness({'login-password':'test-only-password'}); + h.ctx.toggleLoginPasswordVisibility(); + assert.equal(h.fields['login-password'].type,'text'); + assert.equal(h.fields['login-password-toggle'].getAttribute('aria-pressed'),'true'); + h.ctx.setLoginPasswordVisible(false); + assert.equal(h.fields['login-password'].type,'password'); + assert.equal(h.fields['login-password'].value,'test-only-password'); + assert.equal(h.fields['login-password-toggle'].getAttribute('data-i18n'),'login.showPassword'); +}); +test('password form has field-specific errors without requesting a save',async () => { + const h=harness({'auth-new-password':'short','auth-confirm-password':'different'}); + await h.ctx.changePassword(); + assert.equal(h.calls(),0); + assert.equal(h.fields['auth-current-password-error'].textContent,'settingsSecurity.currentRequired'); + assert.equal(h.fields['auth-new-password-error'].textContent,'settingsSecurity.newTooShort'); + assert.equal(h.fields['auth-confirm-password-error'].textContent,'settingsSecurity.confirmMismatch'); + assert.equal(h.fields['auth-current-password'].focused,true); + h.ctx.resetPasswordForm(); + for (const id of ['auth-current-password','auth-new-password','auth-confirm-password']) { + assert.equal(h.fields[id].getAttribute('aria-invalid'),'false'); + assert.equal(h.fields[id+'-error'].hidden,true); + } +}); +test('server password failure is visible in the form and restores the save button',async () => { + const h=harness({'auth-current-password':'test-only-old','auth-new-password':'test-only-new','auth-confirm-password':'test-only-new'}); + await h.ctx.changePassword(); + assert.equal(h.calls(),1); + assert.equal(h.fields['password-form-error'].hidden,false); + assert.equal(h.fields['password-form-error'].textContent,'test-only incorrect current password'); + assert.equal(h.button.disabled,false); +}); diff --git a/web/static/js/settings.js b/web/static/js/settings.js index 43982126..f7699ed6 100644 --- a/web/static/js/settings.js +++ b/web/static/js/settings.js @@ -4062,6 +4062,28 @@ async function saveToolsConfig() { } } +function setPasswordFieldError(input, message) { + if (!input) return; + input.classList.toggle('error', !!message); + input.setAttribute('aria-invalid', message ? 'true' : 'false'); + const error = document.getElementById(input.id + '-error'); + if (error) { + error.textContent = message || ''; + error.hidden = !message; + input.setAttribute('aria-describedby', error.id); + } +} + +function passwordValidationText(key, fallback) { + return typeof window.t === 'function' ? window.t('settingsSecurity.' + key) : fallback; +} + +function clearPasswordFieldError(input) { + setPasswordFieldError(input, ''); + const error = document.getElementById('password-form-error'); + if (error) { error.textContent = ''; error.hidden = true; } +} + function resetPasswordForm() { const currentInput = document.getElementById('auth-current-password'); const newInput = document.getElementById('auth-new-password'); @@ -4070,9 +4092,11 @@ function resetPasswordForm() { [currentInput, newInput, confirmInput].forEach(input => { if (input) { input.value = ''; - input.classList.remove('error'); + setPasswordFieldError(input, ''); } }); + const error = document.getElementById('password-form-error'); + if (error) { error.textContent = ''; error.hidden = true; } } async function changePassword() { @@ -4081,7 +4105,9 @@ async function changePassword() { const confirmInput = document.getElementById('auth-confirm-password'); const submitBtn = document.querySelector('.change-password-submit'); - [currentInput, newInput, confirmInput].forEach(input => input && input.classList.remove('error')); + [currentInput, newInput, confirmInput].forEach(input => setPasswordFieldError(input, '')); + const formError = document.getElementById('password-form-error'); + if (formError) { formError.textContent = ''; formError.hidden = true; } const currentPassword = currentInput?.value.trim() || ''; const newPassword = newInput?.value.trim() || ''; @@ -4090,22 +4116,22 @@ async function changePassword() { let hasError = false; if (!currentPassword) { - currentInput?.classList.add('error'); + setPasswordFieldError(currentInput, passwordValidationText('currentRequired', '请输入当前密码')); hasError = true; } if (!newPassword || newPassword.length < 8) { - newInput?.classList.add('error'); + setPasswordFieldError(newInput, passwordValidationText('newTooShort', '新密码至少需要 8 位')); hasError = true; } if (newPassword !== confirmPassword) { - confirmInput?.classList.add('error'); + setPasswordFieldError(confirmInput, passwordValidationText('confirmMismatch', '两次输入的新密码不一致')); hasError = true; } if (hasError) { - alert(typeof window.t === 'function' ? window.t('settings.security.fillPasswordHint') : '请正确填写当前密码和新密码,新密码至少 8 位且需要两次输入一致。'); + [currentInput, newInput, confirmInput].find(input => input && input.getAttribute('aria-invalid') === 'true')?.focus(); return; } @@ -4137,6 +4163,7 @@ async function changePassword() { closeSettings(); } catch (error) { console.error('修改密码失败:', error); + if (formError) { formError.textContent = error.message; formError.hidden = false; } alert((typeof window.t === 'function' ? window.t('settings.security.changePasswordFailed') : '修改密码失败') + ': ' + error.message); } finally { if (submitBtn) { diff --git a/web/templates/index.html b/web/templates/index.html index 117f5784..3381de7a 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -50,14 +50,17 @@ - +
- +