diff --git a/.project-docs/30-worklog/tasks/20260902-registration-invalid-params-59f94692.md b/.project-docs/30-worklog/tasks/20260902-registration-invalid-params-59f94692.md new file mode 100644 index 0000000..da487df --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260902-registration-invalid-params-59f94692.md @@ -0,0 +1,61 @@ +# Task: Fix account registration invalid request parameters + +## Identity + +- Task ID: 20260902-registration-invalid-params-59f94692 +- Mode: Feature +- Branch: codex/20260902-registration-invalid-params-59f94692-registration-invalid-params-59f94692 +- Worktree: /Users/inmanx/Documents/lwltAPI-registration-invalid-params-59f94692 +- Base commit: 3ed3af1feb503358b98fb57d3e8d97ab59d98129 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Diagnose the current account-creation failure reported as “请求参数不符合要求”。 +- Correlate the operator form contract, API payload, server-side validation, privacy-safe runtime diagnostics, and account authorization tests. +- Add a narrowly scoped client-side validation and error-feedback fix plus regression coverage. +- Do not create or change a real account, read secrets or request payloads, deploy, restart the service, or modify ERP behavior. + +## Intent And Constraints + +- Preserve the accepted fixed-scope `admin` / `team_lead` / `user` account and authorization model. +- Keep the server-side 12–512-character password boundary unchanged; prevent invalid form input from reaching the API and retain a safe backend-validation fallback. +- Use runtime diagnostics only for validation field names; do not persist account names, passwords, request bodies, or other user data. +- Treat the concurrent compact-dashboard task as a file-level overlap warning only; keep this change limited to the account form and its tests for later reconciliation. + +## Outcome + +- Privacy-safe diagnostics from the running standard service showed the two recent HTTP validation failures both had only `validation_paths=["password"]`; no request content was inspected. +- Confirmed the mismatch: the API requires a 12–512-character password, while the account form used `novalidate` and submitted without an equivalent client-side check, causing a short password to surface only as the generic Zod response. +- Added deterministic account-create validation before any API request. Invalid username, password, or role values now show a Chinese field-specific message, mark and focus the relevant field, and do not send a request. +- Preserved safe backend fallback handling by retaining `error_code` and validation paths from API errors and mapping a server-side password rejection to the same actionable message. +- Added the password requirement beside the form field and added focused regression coverage for short-password rejection, valid request normalization, backend fallback messaging, and visible form guidance. +- No live account, database, service process, deployment, ERP state, or external system was changed. + +## Verification + +- Focused account-form regression: 4/4 passed. +- Focused account-authorization regression: 8/8 passed. +- `node --check LianSyn-platform/app.js`: passed. +- `git diff --check`: passed before the final documentation update. +- `node --run check:repo`: 10/10 passed. +- `node --run check`: passed. +- `node --run test:control-plane`: 153/153 passed. +- `node --run test:legacy`: 260/260 passed, including the new four tests. +- `node --run build`: passed. +- `check_project_docs.py`: passed. +- `check_doc_drift.py --task-id 20260902-registration-invalid-params-59f94692`: passed. +- Verification used the bundled Node runtime and a temporary ignored `node_modules` symlink to the existing dependency tree because Node was not on the isolated shell `PATH`. + +## Follow-ups + +- Integrate this isolated feature change with the concurrent dashboard layout work, then restart or redeploy the standard service only under separate explicit authorization before expecting the live `/accounts` page to change. + +## Promotion Candidates + +- None recorded. + +## Supporting Records + +- [Account registration password validation evidence](../../50-evidence/topics/20260902-registration-invalid-params-59f94692__account-registration-password-validation.md) diff --git a/.project-docs/50-evidence/topics/20260902-registration-invalid-params-59f94692__account-registration-password-validation.md b/.project-docs/50-evidence/topics/20260902-registration-invalid-params-59f94692__account-registration-password-validation.md new file mode 100644 index 0000000..8469536 --- /dev/null +++ b/.project-docs/50-evidence/topics/20260902-registration-invalid-params-59f94692__account-registration-password-validation.md @@ -0,0 +1,27 @@ +# Account Registration Password Validation Evidence + +## Source + +- Read-only inspection on 2026-09-02 of the standard service's privacy-safe structured diagnostics. +- Active account form, API request builder, `accountCreateSchema`, authentication validation, and account authorization tests at base commit `3ed3af1feb503358b98fb57d3e8d97ab59d98129`. + +## Finding + +- The two recent `http.request.invalid` events both reported only the validation path `password`. +- The server contract requires an initial password length of 12–512 characters. +- The HTML field declared the same `minlength` and `maxlength`, but the account form used `novalidate` and the JavaScript request path did not perform its own validation before calling `/api/accounts`. +- Therefore a short initial password reached server-side Zod validation and the UI displayed the generic response “请求参数不符合要求。” instead of the actual password requirement. + +## Privacy And Safety + +- Only diagnostic event type and validation field paths were extracted. +- No password, account name, request body, environment file, database row, or other business/user content was read or stored. +- No live account request or runtime mutation was performed. + +## Confidence + +- High. Runtime field-path evidence, the active form behavior, and the server schema all identify the same password-length mismatch. + +## Stale Trigger + +- Reassess if the password contract, account form submission flow, or generic API validation response changes. diff --git a/LianSyn-platform/app-account-form.test.mjs b/LianSyn-platform/app-account-form.test.mjs new file mode 100644 index 0000000..bebd4fc --- /dev/null +++ b/LianSyn-platform/app-account-form.test.mjs @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import vm from 'node:vm'; + +const [appSource, indexSource] = await Promise.all([ + readFile(new URL('./app.js', import.meta.url), 'utf8'), + readFile(new URL('./index.html', import.meta.url), 'utf8') +]); + +function loadNamedFunction(name) { + const start = appSource.indexOf(`function ${name}(`); + assert.notEqual(start, -1, `${name} must exist`); + const signatureEnd = appSource.indexOf(') {', start); + assert.notEqual(signatureEnd, -1, `${name} must have a complete signature`); + const bodyStart = signatureEnd + 2; + let depth = 0; + let end = -1; + for (let index = bodyStart; index < appSource.length; index += 1) { + if (appSource[index] === '{') depth += 1; + if (appSource[index] === '}') { + depth -= 1; + if (depth === 0) { + end = index + 1; + break; + } + } + } + assert.notEqual(end, -1, `${name} must have a complete body`); + const context = {}; + vm.runInNewContext(`${appSource.slice(start, end)}; globalThis.loaded = ${name};`, context); + return context.loaded; +} + +test('account creation rejects a short password before sending the request', () => { + const validate = loadNamedFunction('validateAccountCreationValues'); + const result = validate({ + username: 'operator', + password: '12345678901', + role: 'user', + mustChangePassword: true + }); + assert.equal(result.ok, false); + assert.equal(result.field, 'accountPassword'); + assert.equal(result.message, '初始密码必须为 12—512 个字符。'); + const createStart = appSource.indexOf('async function createAccountFromForm()'); + const createEnd = appSource.indexOf('\n}\n\nasync function updateManagedAccount', createStart); + assert.notEqual(createStart, -1); + assert.notEqual(createEnd, -1); + const createSource = appSource.slice(createStart, createEnd); + assert.ok(createSource.indexOf('if (!validation.ok)') < createSource.indexOf("apiRequest('/api/accounts'")); + assert.match(createSource, /if \(!validation\.ok\) \{[\s\S]+return;[\s\S]+apiRequest\('\/api\/accounts'/u); +}); + +test('account creation normalizes valid form values into the server contract', () => { + const validate = loadNamedFunction('validateAccountCreationValues'); + const result = validate({ + username: ' TeamLead ', + password: 'correct-horse-battery-staple', + role: 'team_lead', + mustChangePassword: true + }); + assert.equal(result.ok, true); + assert.equal(result.body.username, 'TeamLead'); + assert.equal(result.body.password, 'correct-horse-battery-staple'); + assert.equal(result.body.role, 'team_lead'); + assert.equal(result.body.must_change_password, true); + assert.equal(Array.isArray(result.body.business_route_ids), true); + assert.equal(result.body.business_route_ids.length, 0); +}); + +test('account creation translates backend password validation into an actionable message', () => { + const messageFor = loadNamedFunction('accountCreationErrorMessage'); + const message = messageFor({ + errorCode: 'invalid_request', + details: ['password'], + message: '请求参数不符合要求。' + }); + assert.equal(message, '初始密码必须为 12—512 个字符。'); +}); + +test('account form states the password requirement beside the field', () => { + assert.match(indexSource, /初始密码(12—512 个字符)]+minlength="12"[^>]+maxlength="512"/u); +}); diff --git a/LianSyn-platform/app.js b/LianSyn-platform/app.js index 7e953af..30a278b 100644 --- a/LianSyn-platform/app.js +++ b/LianSyn-platform/app.js @@ -262,7 +262,11 @@ async function apiRequest(path, options = {}) { showLoginPanel('登录已过期,请重新登录。'); } if (!response.ok) { - throw new Error(payload?.message || payload?.error || `请求失败:HTTP ${response.status}`); + const requestError = new Error(payload?.message || payload?.error || `请求失败:HTTP ${response.status}`); + requestError.status = response.status; + requestError.errorCode = payload?.error_code || ''; + requestError.details = payload?.details; + throw requestError; } return payload || {}; } @@ -891,6 +895,63 @@ function accountRoleLabel(role) { return '普通用户'; } +function validateAccountCreationValues(values = {}) { + const username = String(values.username || '').trim(); + const password = String(values.password || ''); + const role = String(values.role || ''); + if (!username || username.length > 160) { + return { ok: false, field: 'accountUsername', message: '账号必须为 1—160 个字符。' }; + } + if (password.length < 12 || password.length > 512) { + return { ok: false, field: 'accountPassword', message: '初始密码必须为 12—512 个字符。' }; + } + if (!['admin', 'team_lead', 'user'].includes(role)) { + return { ok: false, field: 'accountRole', message: '请选择有效的账号角色。' }; + } + return { + ok: true, + body: { + username, + password, + role, + must_change_password: values.mustChangePassword === true, + business_route_ids: [] + } + }; +} + +function accountCreationErrorMessage(error) { + const details = Array.isArray(error?.details) ? error.details.map((item) => String(item || '')) : []; + if (error?.errorCode === 'invalid_request') { + if (details.some((path) => path === 'password' || path.startsWith('password.'))) { + return '初始密码必须为 12—512 个字符。'; + } + if (details.some((path) => path === 'username' || path.startsWith('username.'))) { + return '账号必须为 1—160 个字符。'; + } + if (details.some((path) => path === 'role' || path.startsWith('role.'))) { + return '请选择有效的账号角色。'; + } + } + return error?.message || String(error); +} + +function showAccountCreationValidationError(validation) { + const message = $('#accountMessage'); + if (message) message.textContent = validation.message; + const field = validation.field ? document.getElementById(validation.field) : null; + if (field) { + field.setAttribute('aria-invalid', 'true'); + field.focus(); + } +} + +function clearAccountCreationValidationErrors() { + for (const fieldId of ['accountUsername', 'accountPassword', 'accountRole']) { + document.getElementById(fieldId)?.removeAttribute('aria-invalid'); + } +} + function renderAccounts() { const container = $('#accountList'); if (!container) return; @@ -1048,19 +1109,24 @@ async function syncAccounts() { async function createAccountFromForm() { if (!isAdministrator() || accountSettingsBusy) return; + const validation = validateAccountCreationValues({ + username: $('#accountUsername').value, + password: $('#accountPassword').value, + role: $('#accountRole').value, + mustChangePassword: $('#accountMustChangePassword').checked + }); + if (!validation.ok) { + showAccountCreationValidationError(validation); + return; + } + clearAccountCreationValidationErrors(); accountSettingsBusy = true; renderAccounts(); const message = $('#accountMessage'); try { const result = await apiRequest('/api/accounts', { method: 'POST', - body: { - username: String($('#accountUsername').value || '').trim(), - password: String($('#accountPassword').value || ''), - role: $('#accountRole').value, - must_change_password: $('#accountMustChangePassword').checked, - business_route_ids: [] - } + body: validation.body }); if (result.account) accountList = [...accountList.filter((item) => item.id !== result.account.id), result.account] .sort((left, right) => left.username.localeCompare(right.username, 'zh-CN')); @@ -5424,7 +5490,7 @@ document.addEventListener('DOMContentLoaded', async () => { event.preventDefault(); createAccountFromForm().catch((error) => { const message = $('#accountMessage'); - if (message) message.textContent = error.message || String(error); + if (message) message.textContent = accountCreationErrorMessage(error); }); }); $('#accountList')?.addEventListener('change', (event) => { diff --git a/LianSyn-platform/index.html b/LianSyn-platform/index.html index 8ebbd34..864dad1 100644 --- a/LianSyn-platform/index.html +++ b/LianSyn-platform/index.html @@ -96,7 +96,7 @@