fix: explain password validation errors and support login preview

This commit is contained in:
Ed1s0nZ committed 2026-10-06 02:49:17 +08:00
1 parent 8550325dfe
commit 05d7d9ec65
10 files changed
+203 -14

No files matched your search

+6
View File
@@ -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,未加入额外客户端加密协议或改动凭据配置。
+3 -3
View File
@@ -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 | 接上一个,改成中国后,省份还是拼音 | 待核对 |
+27
View File
@@ -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;
+5
View File
@@ -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",
+5
View File
@@ -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": "Текущий пароль",
+5
View File
@@ -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": "当前密码",
+36
View File
@@ -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;
+71
View File
@@ -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'),/<form id="login-form"[^>]*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);
});
+33 -6
View File
@@ -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) {
+12 -5
View File
@@ -50,14 +50,17 @@
</h2>
<p class="login-subtitle" data-i18n="login.subtitle">使用平台账号登录,默认管理员为 admin。</p>
</div>
<form id="login-form" class="login-form">
<form id="login-form" class="login-form" novalidate>
<div class="form-group">
<label for="login-username" data-i18n="login.usernameLabel">用户名</label>
<input type="text" id="login-username" placeholder="admin" required maxlength="64" autocomplete="username" data-i18n="login.usernamePlaceholder" data-i18n-attr="placeholder" />
</div>
<div class="form-group">
<label for="login-password" data-i18n="login.passwordLabel">密码</label>
<input type="password" id="login-password" placeholder="输入登录密码" required autocomplete="current-password" data-i18n="login.passwordPlaceholder" data-i18n-attr="placeholder" />
<div class="login-password-field">
<input type="password" id="login-password" placeholder="输入登录密码" required autocomplete="current-password" aria-describedby="login-error" data-i18n="login.passwordPlaceholder" data-i18n-attr="placeholder" />
<button type="button" id="login-password-toggle" class="btn-secondary" aria-controls="login-password" aria-pressed="false" onclick="toggleLoginPasswordVisibility()" data-i18n="login.showPassword">显示密码</button>
</div>
</div>
<div id="login-error" class="login-error" role="alert" style="display: none;"></div>
<button type="submit" class="btn-primary login-submit">
@@ -5069,16 +5072,20 @@
<div class="settings-form">
<div class="form-group">
<label for="auth-current-password" data-i18n="settingsSecurity.currentPassword">当前密码</label>
<input type="password" id="auth-current-password" data-i18n="settingsSecurity.currentPasswordPlaceholder" data-i18n-attr="placeholder" placeholder="输入当前登录密码" autocomplete="current-password" />
<input type="password" id="auth-current-password" oninput="clearPasswordFieldError(this)" data-i18n="settingsSecurity.currentPasswordPlaceholder" data-i18n-attr="placeholder" placeholder="输入当前登录密码" autocomplete="current-password" />
<small id="auth-current-password-error" class="password-field-error" role="alert" hidden></small>
</div>
<div class="form-group">
<label for="auth-new-password" data-i18n="settingsSecurity.newPassword">新密码</label>
<input type="password" id="auth-new-password" data-i18n="settingsSecurity.newPasswordPlaceholder" data-i18n-attr="placeholder" placeholder="设置新密码(至少 8 位)" autocomplete="new-password" />
<input type="password" id="auth-new-password" oninput="clearPasswordFieldError(this)" data-i18n="settingsSecurity.newPasswordPlaceholder" data-i18n-attr="placeholder" placeholder="设置新密码(至少 8 位)" autocomplete="new-password" />
<small id="auth-new-password-error" class="password-field-error" role="alert" hidden></small>
</div>
<div class="form-group">
<label for="auth-confirm-password" data-i18n="settingsSecurity.confirmPassword">确认新密码</label>
<input type="password" id="auth-confirm-password" data-i18n="settingsSecurity.confirmPasswordPlaceholder" data-i18n-attr="placeholder" placeholder="再次输入新密码" autocomplete="new-password" />
<input type="password" id="auth-confirm-password" oninput="clearPasswordFieldError(this)" data-i18n="settingsSecurity.confirmPasswordPlaceholder" data-i18n-attr="placeholder" placeholder="再次输入新密码" autocomplete="new-password" />
<small id="auth-confirm-password-error" class="password-field-error" role="alert" hidden></small>
</div>
<div id="password-form-error" class="password-field-error" role="alert" hidden></div>
<div class="form-actions">
<button class="btn-secondary" type="button" onclick="resetPasswordForm()" data-i18n="settingsSecurity.clear">清空</button>
<button class="btn-primary change-password-submit" type="button" data-require-permission="auth:self" onclick="changePassword()" data-i18n="settingsSecurity.changePasswordBtn">修改密码</button>