fix: simplify account password flow
This commit is contained in:
1 parent
203bfb3246
commit
df65e9f517
9 files changed
+118
-131
No files matched your search
@@ -14,31 +14,35 @@
|
|||||||
|
|
||||||
- Diagnose the current account-creation failure reported as “请求参数不符合要求”。
|
- 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.
|
- 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.
|
- Apply the user's superseding product decision: passwords have no length restriction beyond being non-empty, and the first-login forced-password-change flow is removed.
|
||||||
|
- Update the account UI, API validation, authentication mapping, compatibility writes, documentation, and regression coverage as one coherent change.
|
||||||
- Do not create or change a real account, read secrets or request payloads, deploy, restart the service, or modify ERP behavior.
|
- Do not create or change a real account, read secrets or request payloads, deploy, restart the service, or modify ERP behavior.
|
||||||
|
|
||||||
## Intent And Constraints
|
## Intent And Constraints
|
||||||
|
|
||||||
- Preserve the accepted fixed-scope `admin` / `team_lead` / `user` account and authorization model.
|
- 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.
|
- Accept any non-empty password for login, account creation, administrator reset, and self-service password change; do not impose a minimum or maximum length in the application contract.
|
||||||
|
- Remove the first-login forced-password-change behavior while retaining voluntary self-service password changes, administrator resets, and session revocation after password changes.
|
||||||
|
- Keep the historical `must_change_password` database column as compatibility-only storage; runtime authorization and UI behavior must not depend on it, and password writes clear it to `false`.
|
||||||
- Use runtime diagnostics only for validation field names; do not persist account names, passwords, request bodies, or other user data.
|
- 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.
|
- Reconcile this isolated feature with concurrent main-branch dashboard work only after the main worktree ownership gate is released.
|
||||||
|
|
||||||
## Outcome
|
## 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.
|
- 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.
|
- Confirmed the original mismatch: the API required 12–512 characters while the form submitted under `novalidate`, so a short password reached Zod validation and surfaced as the generic message.
|
||||||
- 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.
|
- The initial length-guidance fix was superseded by the user's explicit direction. Account creation, reset, login, and self-service change now reject only an empty password and accept short non-empty values.
|
||||||
- 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.
|
- Removed the “首次登录必须修改密码” option, forced-password-change screen state, forced route/mutation gate, response flag, and account-list badge. The normal voluntary “修改密码” control remains available.
|
||||||
- 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.
|
- Existing `must_change_password` values no longer affect sessions or authorization; new account creation and password writes leave or force the compatibility column to `false`.
|
||||||
|
- Added regression assertions covering one-character account passwords, empty-password rejection, absence of length rules, and absence of the first-login forced-change contract.
|
||||||
- No live account, database, service process, deployment, ERP state, or external system was changed.
|
- No live account, database, service process, deployment, ERP state, or external system was changed.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
- Focused account-form regression: 4/4 passed.
|
- Focused account-form regression: 4/4 passed after the superseding product change.
|
||||||
- Focused account-authorization regression: 8/8 passed.
|
- Focused account-authorization regression: 8/8 passed after the superseding product change.
|
||||||
- `node --check LianSyn-platform/app.js`: passed.
|
- `node --check LianSyn-platform/app.js`: passed.
|
||||||
- `git diff --check`: passed before the final documentation update.
|
- `git diff --check`: passed after the final code and documentation update.
|
||||||
- `node --run check:repo`: 10/10 passed.
|
- `node --run check:repo`: 10/10 passed.
|
||||||
- `node --run check`: passed.
|
- `node --run check`: passed.
|
||||||
- `node --run test:control-plane`: 153/153 passed.
|
- `node --run test:control-plane`: 153/153 passed.
|
||||||
@@ -50,11 +54,16 @@
|
|||||||
|
|
||||||
## Follow-ups
|
## 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.
|
- Merge this isolated feature into `main` after the concurrent main-worktree task releases ownership; the user explicitly authorized the merge.
|
||||||
|
- Restart or redeploy the standard service only under separate explicit authorization before expecting backend behavior to change in the running process.
|
||||||
|
|
||||||
## Promotion Candidates
|
## Promotion Candidates
|
||||||
|
|
||||||
- None recorded.
|
- Target documents: `10-project-memory/architecture/system-overview.md`, `10-project-memory/decisions/AUTH-001-fixed-scope-account-isolation.md`, and `20-business-memory/business-rules.md`.
|
||||||
|
- Proposed durable fact: application passwords are required to be non-empty but have no application-level length restriction; first-login forced password changes are disabled. Voluntary password change, administrator reset, and session revocation remain supported.
|
||||||
|
- Evidence: this task's focused regressions, full repository verification, and the linked privacy-safe diagnosis record.
|
||||||
|
- Future-task impact: account UI/API/schema changes must not reintroduce a length rule or `must_change_password`-based gate without a new product decision and migration plan.
|
||||||
|
- Human confirmation: explicitly provided by the user on 2026-09-02.
|
||||||
|
|
||||||
## Supporting Records
|
## Supporting Records
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -8,10 +8,17 @@
|
|||||||
## Finding
|
## Finding
|
||||||
|
|
||||||
- The two recent `http.request.invalid` events both reported only the validation path `password`.
|
- 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.
|
- At the inspected base commit, the server contract required 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`.
|
- 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.
|
- Therefore a short initial password reached server-side Zod validation and the UI displayed the generic response “请求参数不符合要求。” instead of the actual password requirement.
|
||||||
|
|
||||||
|
## User Decision And Resolution
|
||||||
|
|
||||||
|
- The user explicitly superseded the original password-length contract: all password entry points now require only a non-empty value and impose no application-level minimum or maximum length.
|
||||||
|
- The user also directed removal of the first-login forced-password-change flow. The UI option, session flag, route gate, account badge, and forced panel state were removed; voluntary password changes remain available.
|
||||||
|
- The historical `must_change_password` database column is retained only for schema compatibility and is ignored by runtime authorization. Password writes clear it to `false`.
|
||||||
|
- This change was implemented and verified in an isolated feature worktree. No running service was restarted and no live account or database row was mutated.
|
||||||
|
|
||||||
## Privacy And Safety
|
## Privacy And Safety
|
||||||
|
|
||||||
- Only diagnostic event type and validation field paths were extracted.
|
- Only diagnostic event type and validation field paths were extracted.
|
||||||
@@ -20,8 +27,8 @@
|
|||||||
|
|
||||||
## Confidence
|
## Confidence
|
||||||
|
|
||||||
- High. Runtime field-path evidence, the active form behavior, and the server schema all identify the same password-length mismatch.
|
- High. Runtime field-path evidence and the inspected base contract identify the original mismatch; the replacement behavior is directly confirmed by the user's product decision and regression coverage.
|
||||||
|
|
||||||
## Stale Trigger
|
## Stale Trigger
|
||||||
|
|
||||||
- Reassess if the password contract, account form submission flow, or generic API validation response changes.
|
- Reassess if a future product decision introduces password policy requirements, a forced-password-change lifecycle, or a replacement for the compatibility column.
|
||||||
@@ -32,17 +32,18 @@ function loadNamedFunction(name) {
|
|||||||
return context.loaded;
|
return context.loaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('account creation rejects a short password before sending the request', () => {
|
test('account creation accepts any non-empty password and blocks an empty password before the request', () => {
|
||||||
const validate = loadNamedFunction('validateAccountCreationValues');
|
const validate = loadNamedFunction('validateAccountCreationValues');
|
||||||
const result = validate({
|
const empty = validate({
|
||||||
username: 'operator',
|
username: 'operator',
|
||||||
password: '12345678901',
|
password: '',
|
||||||
role: 'user',
|
role: 'user'
|
||||||
mustChangePassword: true
|
|
||||||
});
|
});
|
||||||
assert.equal(result.ok, false);
|
assert.equal(empty.ok, false);
|
||||||
assert.equal(result.field, 'accountPassword');
|
assert.equal(empty.field, 'accountPassword');
|
||||||
assert.equal(result.message, '初始密码必须为 12—512 个字符。');
|
assert.equal(empty.message, '请输入初始密码。');
|
||||||
|
const oneCharacter = validate({ username: 'operator', password: '1', role: 'user' });
|
||||||
|
assert.equal(oneCharacter.ok, true);
|
||||||
const createStart = appSource.indexOf('async function createAccountFromForm()');
|
const createStart = appSource.indexOf('async function createAccountFromForm()');
|
||||||
const createEnd = appSource.indexOf('\n}\n\nasync function updateManagedAccount', createStart);
|
const createEnd = appSource.indexOf('\n}\n\nasync function updateManagedAccount', createStart);
|
||||||
assert.notEqual(createStart, -1);
|
assert.notEqual(createStart, -1);
|
||||||
@@ -56,15 +57,14 @@ test('account creation normalizes valid form values into the server contract', (
|
|||||||
const validate = loadNamedFunction('validateAccountCreationValues');
|
const validate = loadNamedFunction('validateAccountCreationValues');
|
||||||
const result = validate({
|
const result = validate({
|
||||||
username: ' TeamLead ',
|
username: ' TeamLead ',
|
||||||
password: 'correct-horse-battery-staple',
|
password: '123456',
|
||||||
role: 'team_lead',
|
role: 'team_lead'
|
||||||
mustChangePassword: true
|
|
||||||
});
|
});
|
||||||
assert.equal(result.ok, true);
|
assert.equal(result.ok, true);
|
||||||
assert.equal(result.body.username, 'TeamLead');
|
assert.equal(result.body.username, 'TeamLead');
|
||||||
assert.equal(result.body.password, 'correct-horse-battery-staple');
|
assert.equal(result.body.password, '123456');
|
||||||
assert.equal(result.body.role, 'team_lead');
|
assert.equal(result.body.role, 'team_lead');
|
||||||
assert.equal(result.body.must_change_password, true);
|
assert.equal(Object.hasOwn(result.body, 'must_change_password'), false);
|
||||||
assert.equal(Array.isArray(result.body.business_route_ids), true);
|
assert.equal(Array.isArray(result.body.business_route_ids), true);
|
||||||
assert.equal(result.body.business_route_ids.length, 0);
|
assert.equal(result.body.business_route_ids.length, 0);
|
||||||
});
|
});
|
||||||
@@ -76,10 +76,11 @@ test('account creation translates backend password validation into an actionable
|
|||||||
details: ['password'],
|
details: ['password'],
|
||||||
message: '请求参数不符合要求。'
|
message: '请求参数不符合要求。'
|
||||||
});
|
});
|
||||||
assert.equal(message, '初始密码必须为 12—512 个字符。');
|
assert.equal(message, '请输入初始密码。');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('account form states the password requirement beside the field', () => {
|
test('account forms expose no length rule or first-login forced-password flow', () => {
|
||||||
assert.match(indexSource, /初始密码(12—512 个字符)<input id="accountPassword"/u);
|
assert.match(indexSource, /初始密码<input id="accountPassword"[^>]+required>/u);
|
||||||
assert.match(indexSource, /id="accountPassword"[^>]+minlength="12"[^>]+maxlength="512"/u);
|
assert.doesNotMatch(indexSource, /accountMustChangePassword|首次登录必须修改密码|minlength="12"|12—512/u);
|
||||||
|
assert.doesNotMatch(appSource, /passwordChangeForced|must_change_password/u);
|
||||||
});
|
});
|
||||||
+23
-26
@@ -36,7 +36,6 @@ let remoteSyncInProgress = false;
|
|||||||
let syncRequested = false;
|
let syncRequested = false;
|
||||||
let csrfToken = '';
|
let csrfToken = '';
|
||||||
let authUser = null;
|
let authUser = null;
|
||||||
let passwordChangeForced = false;
|
|
||||||
let browserConnectionId = '';
|
let browserConnectionId = '';
|
||||||
let organizationAutomationEnabled = false;
|
let organizationAutomationEnabled = false;
|
||||||
let automationSettingsLoaded = false;
|
let automationSettingsLoaded = false;
|
||||||
@@ -360,7 +359,6 @@ function showAuthChecking() {
|
|||||||
|
|
||||||
function showAuthenticatedApp(user) {
|
function showAuthenticatedApp(user) {
|
||||||
authUser = user;
|
authUser = user;
|
||||||
passwordChangeForced = Boolean(user?.must_change_password);
|
|
||||||
browserConnectionId = connectionIdForUser(user);
|
browserConnectionId = connectionIdForUser(user);
|
||||||
if (!isAdministrator() && IS_ADMIN_PAGE) {
|
if (!isAdministrator() && IS_ADMIN_PAGE) {
|
||||||
window.location.replace('/');
|
window.location.replace('/');
|
||||||
@@ -410,23 +408,19 @@ function showAuthenticatedApp(user) {
|
|||||||
}
|
}
|
||||||
const submitButton = $('#loginForm button[type="submit"]');
|
const submitButton = $('#loginForm button[type="submit"]');
|
||||||
if (submitButton) submitButton.disabled = false;
|
if (submitButton) submitButton.disabled = false;
|
||||||
if (user.must_change_password) showPasswordChangePanel(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showPasswordChangePanel(forced = false) {
|
function showPasswordChangePanel() {
|
||||||
if (!authUser) return;
|
if (!authUser) return;
|
||||||
passwordChangeForced = forced;
|
|
||||||
for (const selector of ['#loginPanel', '#workbench', '#channelsPage', '#parserRoutingPage', '#accountsPage', '#auditPage', '#operationsDashboardPage']) {
|
for (const selector of ['#loginPanel', '#workbench', '#channelsPage', '#parserRoutingPage', '#accountsPage', '#auditPage', '#operationsDashboardPage']) {
|
||||||
const page = $(selector);
|
const page = $(selector);
|
||||||
if (page) page.hidden = true;
|
if (page) page.hidden = true;
|
||||||
}
|
}
|
||||||
const panel = $('#passwordChangePanel');
|
const panel = $('#passwordChangePanel');
|
||||||
if (panel) panel.hidden = false;
|
if (panel) panel.hidden = false;
|
||||||
$('#passwordChangeTitle').textContent = forced ? '请先设置新密码' : '修改密码';
|
$('#passwordChangeTitle').textContent = '修改密码';
|
||||||
$('#passwordChangeHint').textContent = forced
|
$('#passwordChangeHint').textContent = '修改后,其他已登录会话将失效。';
|
||||||
? '管理员已重置你的密码。继续使用平台前,请设置至少 12 个字符的新密码。'
|
$('#passwordChangeCancel').hidden = false;
|
||||||
: '新密码至少 12 个字符。修改后,其他已登录会话将失效。';
|
|
||||||
$('#passwordChangeCancel').hidden = forced;
|
|
||||||
$('#passwordChangeError').textContent = '';
|
$('#passwordChangeError').textContent = '';
|
||||||
$('#changePasswordButton').hidden = true;
|
$('#changePasswordButton').hidden = true;
|
||||||
$('#automationToggleButton').hidden = true;
|
$('#automationToggleButton').hidden = true;
|
||||||
@@ -435,7 +429,7 @@ function showPasswordChangePanel(forced = false) {
|
|||||||
function renderAutomationToggle() {
|
function renderAutomationToggle() {
|
||||||
const button = $('#automationToggleButton');
|
const button = $('#automationToggleButton');
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
button.hidden = !isAdministrator() || IS_MANAGEMENT_PAGE || passwordChangeForced;
|
button.hidden = !isAdministrator() || IS_MANAGEMENT_PAGE;
|
||||||
button.disabled = !authUser || automationSettingsBusy || !automationSettingsLoaded || Boolean(automationSettingsError);
|
button.disabled = !authUser || automationSettingsBusy || !automationSettingsLoaded || Boolean(automationSettingsError);
|
||||||
button.setAttribute('aria-pressed', organizationAutomationEnabled ? 'true' : 'false');
|
button.setAttribute('aria-pressed', organizationAutomationEnabled ? 'true' : 'false');
|
||||||
button.classList.toggle('is-enabled', organizationAutomationEnabled);
|
button.classList.toggle('is-enabled', organizationAutomationEnabled);
|
||||||
@@ -902,8 +896,8 @@ function validateAccountCreationValues(values = {}) {
|
|||||||
if (!username || username.length > 160) {
|
if (!username || username.length > 160) {
|
||||||
return { ok: false, field: 'accountUsername', message: '账号必须为 1—160 个字符。' };
|
return { ok: false, field: 'accountUsername', message: '账号必须为 1—160 个字符。' };
|
||||||
}
|
}
|
||||||
if (password.length < 12 || password.length > 512) {
|
if (!password) {
|
||||||
return { ok: false, field: 'accountPassword', message: '初始密码必须为 12—512 个字符。' };
|
return { ok: false, field: 'accountPassword', message: '请输入初始密码。' };
|
||||||
}
|
}
|
||||||
if (!['admin', 'team_lead', 'user'].includes(role)) {
|
if (!['admin', 'team_lead', 'user'].includes(role)) {
|
||||||
return { ok: false, field: 'accountRole', message: '请选择有效的账号角色。' };
|
return { ok: false, field: 'accountRole', message: '请选择有效的账号角色。' };
|
||||||
@@ -914,7 +908,6 @@ function validateAccountCreationValues(values = {}) {
|
|||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
role,
|
role,
|
||||||
must_change_password: values.mustChangePassword === true,
|
|
||||||
business_route_ids: []
|
business_route_ids: []
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -924,7 +917,7 @@ function accountCreationErrorMessage(error) {
|
|||||||
const details = Array.isArray(error?.details) ? error.details.map((item) => String(item || '')) : [];
|
const details = Array.isArray(error?.details) ? error.details.map((item) => String(item || '')) : [];
|
||||||
if (error?.errorCode === 'invalid_request') {
|
if (error?.errorCode === 'invalid_request') {
|
||||||
if (details.some((path) => path === 'password' || path.startsWith('password.'))) {
|
if (details.some((path) => path === 'password' || path.startsWith('password.'))) {
|
||||||
return '初始密码必须为 12—512 个字符。';
|
return '请输入初始密码。';
|
||||||
}
|
}
|
||||||
if (details.some((path) => path === 'username' || path.startsWith('username.'))) {
|
if (details.some((path) => path === 'username' || path.startsWith('username.'))) {
|
||||||
return '账号必须为 1—160 个字符。';
|
return '账号必须为 1—160 个字符。';
|
||||||
@@ -970,7 +963,7 @@ function renderAccounts() {
|
|||||||
heading.append(el('strong', '', account.username));
|
heading.append(el('strong', '', account.username));
|
||||||
heading.append(el('span', `state ${account.is_active ? 'state-ok' : 'state-bad'}`, account.is_active ? '有效' : '已停用'));
|
heading.append(el('span', `state ${account.is_active ? 'state-ok' : 'state-bad'}`, account.is_active ? '有效' : '已停用'));
|
||||||
main.append(heading);
|
main.append(heading);
|
||||||
main.append(el('p', 'muted', `${accountRoleLabel(account.role)}${account.must_change_password ? ' · 待修改密码' : ''}`));
|
main.append(el('p', 'muted', accountRoleLabel(account.role)));
|
||||||
const authorizedCount = Array.isArray(account.authorized_business_route_ids)
|
const authorizedCount = Array.isArray(account.authorized_business_route_ids)
|
||||||
? account.authorized_business_route_ids.length
|
? account.authorized_business_route_ids.length
|
||||||
: 0;
|
: 0;
|
||||||
@@ -1112,8 +1105,7 @@ async function createAccountFromForm() {
|
|||||||
const validation = validateAccountCreationValues({
|
const validation = validateAccountCreationValues({
|
||||||
username: $('#accountUsername').value,
|
username: $('#accountUsername').value,
|
||||||
password: $('#accountPassword').value,
|
password: $('#accountPassword').value,
|
||||||
role: $('#accountRole').value,
|
role: $('#accountRole').value
|
||||||
mustChangePassword: $('#accountMustChangePassword').checked
|
|
||||||
});
|
});
|
||||||
if (!validation.ok) {
|
if (!validation.ok) {
|
||||||
showAccountCreationValidationError(validation);
|
showAccountCreationValidationError(validation);
|
||||||
@@ -1158,14 +1150,19 @@ async function updateManagedAccount(accountId, patch) {
|
|||||||
|
|
||||||
async function resetManagedAccountPassword(accountId) {
|
async function resetManagedAccountPassword(accountId) {
|
||||||
if (!isAdministrator() || accountSettingsBusy) return;
|
if (!isAdministrator() || accountSettingsBusy) return;
|
||||||
const password = window.prompt('请输入至少 12 个字符的新密码。账号下次登录时必须修改此密码:');
|
const password = window.prompt('请输入新密码:');
|
||||||
if (password == null) return;
|
if (password == null) return;
|
||||||
|
if (!password) {
|
||||||
|
const message = $('#accountMessage');
|
||||||
|
if (message) message.textContent = '密码不能为空。';
|
||||||
|
return;
|
||||||
|
}
|
||||||
accountSettingsBusy = true;
|
accountSettingsBusy = true;
|
||||||
renderAccounts();
|
renderAccounts();
|
||||||
try {
|
try {
|
||||||
await apiRequest(`/api/accounts/${encodeURIComponent(accountId)}/reset-password`, {
|
await apiRequest(`/api/accounts/${encodeURIComponent(accountId)}/reset-password`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { password, must_change_password: true }
|
body: { password }
|
||||||
});
|
});
|
||||||
const message = $('#accountMessage');
|
const message = $('#accountMessage');
|
||||||
if (message) message.textContent = '密码已重置,该账号的现有会话已全部撤销。';
|
if (message) message.textContent = '密码已重置,该账号的现有会话已全部撤销。';
|
||||||
@@ -5219,7 +5216,6 @@ async function initializeSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showAuthenticatedApp(me.user);
|
showAuthenticatedApp(me.user);
|
||||||
if (me.user?.must_change_password) return true;
|
|
||||||
if (isAdministrator()) {
|
if (isAdministrator()) {
|
||||||
try {
|
try {
|
||||||
await syncAutomationSettings();
|
await syncAutomationSettings();
|
||||||
@@ -5320,7 +5316,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
csrfToken = result.csrf_token || '';
|
csrfToken = result.csrf_token || '';
|
||||||
showAuthenticatedApp(result.user);
|
showAuthenticatedApp(result.user);
|
||||||
$('#loginPassword').value = '';
|
$('#loginPassword').value = '';
|
||||||
if (result.user?.must_change_password) return;
|
|
||||||
if (isAdministrator()) {
|
if (isAdministrator()) {
|
||||||
await syncAutomationSettings().catch(() => setTaskState('全自动化设置读取失败'));
|
await syncAutomationSettings().catch(() => setTaskState('全自动化设置读取失败'));
|
||||||
}
|
}
|
||||||
@@ -5343,9 +5338,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
if (submitButton) submitButton.disabled = false;
|
if (submitButton) submitButton.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
$('#changePasswordButton').addEventListener('click', () => showPasswordChangePanel(false));
|
$('#changePasswordButton').addEventListener('click', showPasswordChangePanel);
|
||||||
$('#passwordChangeCancel').addEventListener('click', () => {
|
$('#passwordChangeCancel').addEventListener('click', () => {
|
||||||
if (passwordChangeForced || !authUser) return;
|
if (!authUser) return;
|
||||||
showAuthenticatedApp(authUser);
|
showAuthenticatedApp(authUser);
|
||||||
});
|
});
|
||||||
$('#passwordChangeForm').addEventListener('submit', async (event) => {
|
$('#passwordChangeForm').addEventListener('submit', async (event) => {
|
||||||
@@ -5354,6 +5349,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
const currentPassword = String($('#currentPassword').value || '');
|
const currentPassword = String($('#currentPassword').value || '');
|
||||||
const newPassword = String($('#newPassword').value || '');
|
const newPassword = String($('#newPassword').value || '');
|
||||||
const confirmation = String($('#confirmNewPassword').value || '');
|
const confirmation = String($('#confirmNewPassword').value || '');
|
||||||
|
if (!currentPassword || !newPassword) {
|
||||||
|
errorNode.textContent = '当前密码和新密码不能为空。';
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (newPassword !== confirmation) {
|
if (newPassword !== confirmation) {
|
||||||
errorNode.textContent = '两次输入的新密码不一致。';
|
errorNode.textContent = '两次输入的新密码不一致。';
|
||||||
return;
|
return;
|
||||||
@@ -5369,7 +5368,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
$('#currentPassword').value = '';
|
$('#currentPassword').value = '';
|
||||||
$('#newPassword').value = '';
|
$('#newPassword').value = '';
|
||||||
$('#confirmNewPassword').value = '';
|
$('#confirmNewPassword').value = '';
|
||||||
passwordChangeForced = false;
|
|
||||||
await initializeSession();
|
await initializeSession();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorNode.textContent = error.message || String(error);
|
errorNode.textContent = error.message || String(error);
|
||||||
@@ -5773,7 +5771,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
void copyTaskLifecycle(button);
|
void copyTaskLifecycle(button);
|
||||||
});
|
});
|
||||||
if (await initializeSession()) {
|
if (await initializeSession()) {
|
||||||
if (authUser?.must_change_password) return;
|
|
||||||
if (IS_TASK_PAGE) renderTaskCards();
|
if (IS_TASK_PAGE) renderTaskCards();
|
||||||
pingAi().catch(() => {});
|
pingAi().catch(() => {});
|
||||||
pingBridge().catch(() => {});
|
pingBridge().catch(() => {});
|
||||||
|
|||||||
@@ -71,11 +71,11 @@
|
|||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<p class="eyebrow">ACCOUNT SECURITY</p>
|
<p class="eyebrow">ACCOUNT SECURITY</p>
|
||||||
<h1 id="passwordChangeTitle">修改密码</h1>
|
<h1 id="passwordChangeTitle">修改密码</h1>
|
||||||
<p id="passwordChangeHint" class="muted">新密码至少 12 个字符。修改后,其他已登录会话将失效。</p>
|
<p id="passwordChangeHint" class="muted">修改后,其他已登录会话将失效。</p>
|
||||||
<form id="passwordChangeForm" novalidate>
|
<form id="passwordChangeForm" novalidate>
|
||||||
<label>当前密码<input id="currentPassword" type="password" autocomplete="current-password" required></label>
|
<label>当前密码<input id="currentPassword" type="password" autocomplete="current-password" required></label>
|
||||||
<label>新密码<input id="newPassword" type="password" autocomplete="new-password" minlength="12" required></label>
|
<label>新密码<input id="newPassword" type="password" autocomplete="new-password" required></label>
|
||||||
<label>确认新密码<input id="confirmNewPassword" type="password" autocomplete="new-password" minlength="12" required></label>
|
<label>确认新密码<input id="confirmNewPassword" type="password" autocomplete="new-password" required></label>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit">保存新密码</button>
|
<button type="submit">保存新密码</button>
|
||||||
<button id="passwordChangeCancel" type="button" class="secondary-button">取消</button>
|
<button id="passwordChangeCancel" type="button" class="secondary-button">取消</button>
|
||||||
@@ -96,9 +96,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<form id="accountForm" class="channel-form" novalidate>
|
<form id="accountForm" class="channel-form" novalidate>
|
||||||
<label>账号<input id="accountUsername" maxlength="160" autocomplete="off" required></label>
|
<label>账号<input id="accountUsername" maxlength="160" autocomplete="off" required></label>
|
||||||
<label>初始密码(12—512 个字符)<input id="accountPassword" type="password" minlength="12" maxlength="512" autocomplete="new-password" required></label>
|
<label>初始密码<input id="accountPassword" type="password" autocomplete="new-password" required></label>
|
||||||
<label>角色<select id="accountRole"><option value="user">普通用户</option><option value="team_lead">组长</option><option value="admin">管理员</option></select></label>
|
<label>角色<select id="accountRole"><option value="user">普通用户</option><option value="team_lead">组长</option><option value="admin">管理员</option></select></label>
|
||||||
<label class="history-select-all"><input id="accountMustChangePassword" type="checkbox" checked>首次登录必须修改密码</label>
|
|
||||||
<button type="submit">创建账号</button>
|
<button type="submit">创建账号</button>
|
||||||
</form>
|
</form>
|
||||||
<section id="accountAuthorizationPanel" class="account-authorization-panel" hidden aria-labelledby="accountAuthorizationTitle">
|
<section id="accountAuthorizationPanel" class="account-authorization-panel" hidden aria-labelledby="accountAuthorizationTitle">
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ Auto 一旦发生 AI fallback,任务会永久绑定原 AI 会话。每次解
|
|||||||
- `POST /api/tasks/:taskId/reparse`
|
- `POST /api/tasks/:taskId/reparse`
|
||||||
- `PUT /api/parser-decisions/:decisionId/review`
|
- `PUT /api/parser-decisions/:decisionId/review`
|
||||||
|
|
||||||
迁移 `013_business_parser_modes` 增加内部固定范围的路由设置、任务快照和加密的 `parse_decisions`;迁移 `014_task_input_attachments` 增加名单输入附件元数据、加密 canonical TSV 与 `awaiting_attachment` 索引;迁移 `015_account_roles_and_task_audit` 增加账号角色、强制改密、输入/附件操作者、任务归档和账号级幂等;迁移 `016_team_lead_operations_dashboard` 增加组长角色与人工指令看板索引;迁移 `017_user_business_route_authorizations` 增加逐账号业务白名单、授权人和乐观并发 revision。原文、完整程序/AI 候选、名单 canonical 中间文本和人工说明使用字段加密保存;统计、全局审计和运行日志不复制明文业务输入。
|
迁移 `013_business_parser_modes` 增加内部固定范围的路由设置、任务快照和加密的 `parse_decisions`;迁移 `014_task_input_attachments` 增加名单输入附件元数据、加密 canonical TSV 与 `awaiting_attachment` 索引;迁移 `015_account_roles_and_task_audit` 增加账号角色、密码更新时间、输入/附件操作者、任务归档和账号级幂等(历史 `must_change_password` 列仅保留兼容,当前流程不启用首次强制改密);迁移 `016_team_lead_operations_dashboard` 增加组长角色与人工指令看板索引;迁移 `017_user_business_route_authorizations` 增加逐账号业务白名单、授权人和乐观并发 revision。原文、完整程序/AI 候选、名单 canonical 中间文本和人工说明使用字段加密保存;统计、全局审计和运行日志不复制明文业务输入。
|
||||||
|
|
||||||
## AgentBus Bot 接入
|
## AgentBus Bot 接入
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ npm install
|
|||||||
npm run check
|
npm run check
|
||||||
npm run build
|
npm run build
|
||||||
npm run db:migrate
|
npm run db:migrate
|
||||||
ADMIN_USERNAME=admin ADMIN_PASSWORD='replace-with-12-plus-chars' npm run admin -- bootstrap
|
ADMIN_USERNAME=admin ADMIN_PASSWORD='replace-with-password' npm run admin -- bootstrap
|
||||||
npm run data:retention
|
npm run data:retention
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
@@ -148,7 +148,7 @@ npm run dev
|
|||||||
1. 复制 `.env.production.example` 为部署机受保护的 `.env.production`,填入 `DEPLOYMENT_REVISION`、PostgreSQL URL、`DATABASE_SCHEMA`、字段加密密钥、外部解析 Key 和 OSS 凭据,并确认 `AGENTBUS_LOG_PAYLOADS=false`。生产数据库可使用现有 PostgreSQL 实例中的新 Schema;迁移程序会创建 Schema,不会触碰其他 Schema 的测试表。
|
1. 复制 `.env.production.example` 为部署机受保护的 `.env.production`,填入 `DEPLOYMENT_REVISION`、PostgreSQL URL、`DATABASE_SCHEMA`、字段加密密钥、外部解析 Key 和 OSS 凭据,并确认 `AGENTBUS_LOG_PAYLOADS=false`。生产数据库可使用现有 PostgreSQL 实例中的新 Schema;迁移程序会创建 Schema,不会触碰其他 Schema 的测试表。
|
||||||
2. 在正式数据库上线前执行并验证备份:`infra/backup-postgres.sh`。
|
2. 在正式数据库上线前执行并验证备份:`infra/backup-postgres.sh`。
|
||||||
3. 使用 `docker compose --env-file .env.production up -d --build` 启动;Compose 会先执行数据库迁移,再启动控制平面。Compose 中的本地 PostgreSQL 仅用于 `--profile local`,生产 `DATABASE_URL` 指向受保护的远程数据库。
|
3. 使用 `docker compose --env-file .env.production up -d --build` 启动;Compose 会先执行数据库迁移,再启动控制平面。Compose 中的本地 PostgreSQL 仅用于 `--profile local`,生产 `DATABASE_URL` 指向受保护的远程数据库。
|
||||||
4. 首次启动后在容器内通过 `docker compose exec -e ADMIN_USERNAME=admin -e ADMIN_PASSWORD='replace-with-12-plus-chars' control-plane node .build/control-plane/src/admin-cli.js bootstrap` 创建管理员;不要把密码写入镜像或 Git。
|
4. 首次启动后在容器内通过 `docker compose exec -e ADMIN_USERNAME=admin -e ADMIN_PASSWORD='replace-with-password' control-plane node .build/control-plane/src/admin-cli.js bootstrap` 创建管理员;不要把密码写入镜像或 Git。
|
||||||
5. 配置 `infra/Caddyfile` 中的正式域名和 HTTPS,然后在 Chrome 插件中加载对应生产业务页面。
|
5. 配置 `infra/Caddyfile` 中的正式域名和 HTTPS,然后在 Chrome 插件中加载对应生产业务页面。
|
||||||
6. 启动后运行 `sh infra/diagnose-server.sh --since 10m`,确认容器、readiness、`service.listening`、AgentBus session 与当前 `deployment_revision`。
|
6. 启动后运行 `sh infra/diagnose-server.sh --since 10m`,确认容器、readiness、`service.listening`、AgentBus session 与当前 `deployment_revision`。
|
||||||
|
|
||||||
|
|||||||
+19
-30
@@ -20,7 +20,6 @@ export interface AuthUser {
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: AuthRole;
|
role: AuthRole;
|
||||||
mustChangePassword: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicAccount {
|
export interface PublicAccount {
|
||||||
@@ -28,7 +27,6 @@ export interface PublicAccount {
|
|||||||
username: string;
|
username: string;
|
||||||
role: AuthRole;
|
role: AuthRole;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
must_change_password: boolean;
|
|
||||||
authorized_business_route_ids: BusinessRouteId[];
|
authorized_business_route_ids: BusinessRouteId[];
|
||||||
business_authorization_revision: number;
|
business_authorization_revision: number;
|
||||||
last_login_at: string | null;
|
last_login_at: string | null;
|
||||||
@@ -74,8 +72,8 @@ function validateUsername(value: string): string {
|
|||||||
|
|
||||||
function validatePassword(value: string): string {
|
function validatePassword(value: string): string {
|
||||||
const password = String(value || '');
|
const password = String(value || '');
|
||||||
if (password.length < 12 || password.length > 512) {
|
if (!password) {
|
||||||
throw new AuthError('password_invalid', '密码必须为 12—512 个字符。', 400);
|
throw new AuthError('password_invalid', '密码不能为空。', 400);
|
||||||
}
|
}
|
||||||
return password;
|
return password;
|
||||||
}
|
}
|
||||||
@@ -111,8 +109,7 @@ function mapUser(row: Record<string, unknown>): AuthUser {
|
|||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
organizationId: String(row.organization_id),
|
organizationId: String(row.organization_id),
|
||||||
username: String(row.username),
|
username: String(row.username),
|
||||||
role: normalizeRole(row.role),
|
role: normalizeRole(row.role)
|
||||||
mustChangePassword: row.must_change_password === true || String(row.must_change_password) === 'true'
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +123,6 @@ function mapAccount(row: Record<string, unknown>): PublicAccount {
|
|||||||
username: String(row.username),
|
username: String(row.username),
|
||||||
role,
|
role,
|
||||||
is_active: row.is_active === true || String(row.is_active) === 'true',
|
is_active: row.is_active === true || String(row.is_active) === 'true',
|
||||||
must_change_password: row.must_change_password === true || String(row.must_change_password) === 'true',
|
|
||||||
authorized_business_route_ids: role === 'admin' ? [...ALL_BUSINESS_ROUTE_IDS] : storedRouteIds,
|
authorized_business_route_ids: role === 'admin' ? [...ALL_BUSINESS_ROUTE_IDS] : storedRouteIds,
|
||||||
business_authorization_revision: Math.max(0, Number(row.business_authorization_revision || 0)),
|
business_authorization_revision: Math.max(0, Number(row.business_authorization_revision || 0)),
|
||||||
last_login_at: isoOrNull(row.last_login_at),
|
last_login_at: isoOrNull(row.last_login_at),
|
||||||
@@ -141,7 +137,7 @@ async function loadPublicAccount(
|
|||||||
userId: string
|
userId: string
|
||||||
): Promise<PublicAccount | null> {
|
): Promise<PublicAccount | null> {
|
||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
`SELECT u.id, u.username, u.role, u.is_active, u.must_change_password,
|
`SELECT u.id, u.username, u.role, u.is_active,
|
||||||
u.business_authorization_revision,
|
u.business_authorization_revision,
|
||||||
u.last_login_at, u.created_at, u.updated_at,
|
u.last_login_at, u.created_at, u.updated_at,
|
||||||
COALESCE(ARRAY(
|
COALESCE(ARRAY(
|
||||||
@@ -217,13 +213,13 @@ export class AuthService {
|
|||||||
must_change_password = false, password_changed_at = now(),
|
must_change_password = false, password_changed_at = now(),
|
||||||
failed_login_count = 0, locked_until = NULL, updated_at = now()
|
failed_login_count = 0, locked_until = NULL, updated_at = now()
|
||||||
WHERE id = $2
|
WHERE id = $2
|
||||||
RETURNING id, organization_id, username, role, must_change_password`,
|
RETURNING id, organization_id, username, role`,
|
||||||
[passwordHash, existing.rows[0].id]
|
[passwordHash, existing.rows[0].id]
|
||||||
)
|
)
|
||||||
: await client.query(
|
: await client.query(
|
||||||
`INSERT INTO users (organization_id, username, password_hash, role, must_change_password)
|
`INSERT INTO users (organization_id, username, password_hash, role)
|
||||||
VALUES ($1, $2, $3, 'admin', false)
|
VALUES ($1, $2, $3, 'admin')
|
||||||
RETURNING id, organization_id, username, role, must_change_password`,
|
RETURNING id, organization_id, username, role`,
|
||||||
[organization.id, normalized, passwordHash]
|
[organization.id, normalized, passwordHash]
|
||||||
);
|
);
|
||||||
const user = mapUser(result.rows[0]);
|
const user = mapUser(result.rows[0]);
|
||||||
@@ -242,7 +238,7 @@ export class AuthService {
|
|||||||
const pool = getPool(this.config);
|
const pool = getPool(this.config);
|
||||||
const lookup = await pool.query(
|
const lookup = await pool.query(
|
||||||
`SELECT id, organization_id, username, password_hash, role, is_active,
|
`SELECT id, organization_id, username, password_hash, role, is_active,
|
||||||
must_change_password, failed_login_count, locked_until
|
failed_login_count, locked_until
|
||||||
FROM users
|
FROM users
|
||||||
WHERE organization_id = (SELECT id FROM organizations WHERE slug = $1)
|
WHERE organization_id = (SELECT id FROM organizations WHERE slug = $1)
|
||||||
AND username = $2`,
|
AND username = $2`,
|
||||||
@@ -334,7 +330,7 @@ export class AuthService {
|
|||||||
async listAccounts(actor: AuthUser): Promise<PublicAccount[]> {
|
async listAccounts(actor: AuthUser): Promise<PublicAccount[]> {
|
||||||
this.requireAdmin(actor);
|
this.requireAdmin(actor);
|
||||||
const result = await getPool(this.config).query(
|
const result = await getPool(this.config).query(
|
||||||
`SELECT u.id, u.username, u.role, u.is_active, u.must_change_password,
|
`SELECT u.id, u.username, u.role, u.is_active,
|
||||||
u.business_authorization_revision,
|
u.business_authorization_revision,
|
||||||
u.last_login_at, u.created_at, u.updated_at,
|
u.last_login_at, u.created_at, u.updated_at,
|
||||||
COALESCE(ARRAY(
|
COALESCE(ARRAY(
|
||||||
@@ -357,7 +353,6 @@ export class AuthService {
|
|||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
role: AuthRole;
|
role: AuthRole;
|
||||||
mustChangePassword?: boolean;
|
|
||||||
businessRouteIds?: readonly string[];
|
businessRouteIds?: readonly string[];
|
||||||
},
|
},
|
||||||
requestId: string
|
requestId: string
|
||||||
@@ -366,7 +361,6 @@ export class AuthService {
|
|||||||
const username = validateUsername(input.username);
|
const username = validateUsername(input.username);
|
||||||
const passwordHash = await argon2.hash(validatePassword(input.password), { type: argon2.argon2id });
|
const passwordHash = await argon2.hash(validatePassword(input.password), { type: argon2.argon2id });
|
||||||
const role = normalizeRole(input.role);
|
const role = normalizeRole(input.role);
|
||||||
const mustChangePassword = input.mustChangePassword !== false;
|
|
||||||
const businessRouteIds = role === 'admin' ? [] : normalizeBusinessRouteIds(input.businessRouteIds);
|
const businessRouteIds = role === 'admin' ? [] : normalizeBusinessRouteIds(input.businessRouteIds);
|
||||||
return withTransaction(this.config, async (client) => {
|
return withTransaction(this.config, async (client) => {
|
||||||
await client.query(
|
await client.query(
|
||||||
@@ -380,10 +374,10 @@ export class AuthService {
|
|||||||
if (existing.rowCount) throw new AuthError('account_exists', '该账号已存在。', 409);
|
if (existing.rowCount) throw new AuthError('account_exists', '该账号已存在。', 409);
|
||||||
const created = await client.query(
|
const created = await client.query(
|
||||||
`INSERT INTO users
|
`INSERT INTO users
|
||||||
(organization_id, username, password_hash, role, must_change_password, password_changed_at)
|
(organization_id, username, password_hash, role, password_changed_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, now())
|
VALUES ($1, $2, $3, $4, now())
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[actor.organizationId, username, passwordHash, role, mustChangePassword]
|
[actor.organizationId, username, passwordHash, role]
|
||||||
);
|
);
|
||||||
const accountId = String(created.rows[0].id);
|
const accountId = String(created.rows[0].id);
|
||||||
if (businessRouteIds.length) {
|
if (businessRouteIds.length) {
|
||||||
@@ -399,7 +393,6 @@ export class AuthService {
|
|||||||
if (!account) throw new AuthError('account_not_found', '账号创建后未能读取。', 500);
|
if (!account) throw new AuthError('account_not_found', '账号创建后未能读取。', 500);
|
||||||
await this.accountAudit(client, actor, 'account.created', account.id, requestId, {
|
await this.accountAudit(client, actor, 'account.created', account.id, requestId, {
|
||||||
role,
|
role,
|
||||||
must_change_password: mustChangePassword,
|
|
||||||
authorized_business_route_ids: account.authorized_business_route_ids
|
authorized_business_route_ids: account.authorized_business_route_ids
|
||||||
});
|
});
|
||||||
return account;
|
return account;
|
||||||
@@ -418,7 +411,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
return withTransaction(this.config, async (client) => {
|
return withTransaction(this.config, async (client) => {
|
||||||
const target = await client.query(
|
const target = await client.query(
|
||||||
`SELECT id, username, role, is_active, must_change_password,
|
`SELECT id, username, role, is_active,
|
||||||
last_login_at, created_at, updated_at
|
last_login_at, created_at, updated_at
|
||||||
FROM users
|
FROM users
|
||||||
WHERE organization_id = $1 AND id = $2
|
WHERE organization_id = $1 AND id = $2
|
||||||
@@ -549,7 +542,6 @@ export class AuthService {
|
|||||||
actor: AuthUser,
|
actor: AuthUser,
|
||||||
targetUserId: string,
|
targetUserId: string,
|
||||||
password: string,
|
password: string,
|
||||||
mustChangePassword: boolean,
|
|
||||||
requestId: string
|
requestId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.requireAdmin(actor);
|
this.requireAdmin(actor);
|
||||||
@@ -562,18 +554,17 @@ export class AuthService {
|
|||||||
if (!target.rowCount) throw new AuthError('account_not_found', '账号不存在。', 404);
|
if (!target.rowCount) throw new AuthError('account_not_found', '账号不存在。', 404);
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE users
|
`UPDATE users
|
||||||
SET password_hash = $1, must_change_password = $2,
|
SET password_hash = $1, must_change_password = false,
|
||||||
password_changed_at = now(), failed_login_count = 0,
|
password_changed_at = now(), failed_login_count = 0,
|
||||||
locked_until = NULL, updated_at = now()
|
locked_until = NULL, updated_at = now()
|
||||||
WHERE id = $3`,
|
WHERE id = $2`,
|
||||||
[passwordHash, mustChangePassword, targetUserId]
|
[passwordHash, targetUserId]
|
||||||
);
|
);
|
||||||
const revoked = await client.query(
|
const revoked = await client.query(
|
||||||
'UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL',
|
'UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL',
|
||||||
[targetUserId]
|
[targetUserId]
|
||||||
);
|
);
|
||||||
await this.accountAudit(client, actor, 'account.password_reset', targetUserId, requestId, {
|
await this.accountAudit(client, actor, 'account.password_reset', targetUserId, requestId, {
|
||||||
must_change_password: mustChangePassword,
|
|
||||||
sessions_revoked: revoked.rowCount || 0
|
sessions_revoked: revoked.rowCount || 0
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -657,8 +648,7 @@ export class AuthService {
|
|||||||
async getActiveSession(token: string | undefined): Promise<ActiveSession | null> {
|
async getActiveSession(token: string | undefined): Promise<ActiveSession | null> {
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
const result = await getPool(this.config).query(
|
const result = await getPool(this.config).query(
|
||||||
`SELECT s.id AS session_id, s.csrf_token_hash, u.id, u.organization_id, u.username, u.role,
|
`SELECT s.id AS session_id, s.csrf_token_hash, u.id, u.organization_id, u.username, u.role
|
||||||
u.must_change_password
|
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN users u ON u.id = s.user_id
|
JOIN users u ON u.id = s.user_id
|
||||||
WHERE s.token_hash = $1
|
WHERE s.token_hash = $1
|
||||||
@@ -681,8 +671,7 @@ export class AuthService {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
organization_id: row.organization_id,
|
organization_id: row.organization_id,
|
||||||
username: row.username,
|
username: row.username,
|
||||||
role: row.role,
|
role: row.role
|
||||||
must_change_password: row.must_change_password
|
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-33
@@ -51,19 +51,18 @@ export function aiServiceConnected(databaseIsReady: boolean, probe: Record<strin
|
|||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
username: z.string().min(1).max(160),
|
username: z.string().min(1).max(160),
|
||||||
password: z.string().min(1).max(512)
|
password: z.string().min(1)
|
||||||
});
|
});
|
||||||
|
|
||||||
const changePasswordSchema = z.object({
|
const changePasswordSchema = z.object({
|
||||||
current_password: z.string().min(1).max(512),
|
current_password: z.string().min(1),
|
||||||
new_password: z.string().min(12).max(512)
|
new_password: z.string().min(1)
|
||||||
});
|
});
|
||||||
|
|
||||||
const accountCreateSchema = z.object({
|
const accountCreateSchema = z.object({
|
||||||
username: z.string().min(1).max(160),
|
username: z.string().min(1).max(160),
|
||||||
password: z.string().min(12).max(512),
|
password: z.string().min(1),
|
||||||
role: z.enum(['admin', 'team_lead', 'user']).default('user'),
|
role: z.enum(['admin', 'team_lead', 'user']).default('user'),
|
||||||
must_change_password: z.boolean().default(true),
|
|
||||||
business_route_ids: z.array(
|
business_route_ids: z.array(
|
||||||
z.string().trim().refine((routeId) => Boolean(businessRouteById(routeId)), '业务类型不存在。')
|
z.string().trim().refine((routeId) => Boolean(businessRouteById(routeId)), '业务类型不存在。')
|
||||||
).max(BUSINESS_ROUTES.length).default([])
|
).max(BUSINESS_ROUTES.length).default([])
|
||||||
@@ -78,8 +77,7 @@ const accountUpdateSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const accountPasswordResetSchema = z.object({
|
const accountPasswordResetSchema = z.object({
|
||||||
password: z.string().min(12).max(512),
|
password: z.string().min(1)
|
||||||
must_change_password: z.boolean().default(true)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const accountBusinessAuthorizationsSchema = z.object({
|
const accountBusinessAuthorizationsSchema = z.object({
|
||||||
@@ -363,8 +361,7 @@ function publicUser(session: ActiveSession) {
|
|||||||
return {
|
return {
|
||||||
id: session.user.id,
|
id: session.user.id,
|
||||||
username: session.user.username,
|
username: session.user.username,
|
||||||
role: session.user.role,
|
role: session.user.role
|
||||||
must_change_password: session.user.mustChangePassword
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,14 +513,6 @@ export async function buildServer({
|
|||||||
return session;
|
return session;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getReadySession = async (request: FastifyRequest): Promise<ActiveSession> => {
|
|
||||||
const session = await getSession(request);
|
|
||||||
if (session.user.mustChangePassword) {
|
|
||||||
throw new AuthError('password_change_required', '首次登录或密码重置后必须先修改密码。', 403);
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
};
|
|
||||||
|
|
||||||
const requireAdmin = (session: ActiveSession): ActiveSession => {
|
const requireAdmin = (session: ActiveSession): ActiveSession => {
|
||||||
if (session.user.role !== 'admin') throw new AuthError('admin_required', '需要管理员权限。', 403);
|
if (session.user.role !== 'admin') throw new AuthError('admin_required', '需要管理员权限。', 403);
|
||||||
return session;
|
return session;
|
||||||
@@ -554,16 +543,10 @@ export async function buildServer({
|
|||||||
return session;
|
return session;
|
||||||
};
|
};
|
||||||
|
|
||||||
const requireMutationSession = async (request: FastifyRequest): Promise<ActiveSession> => {
|
const requireMutationSession = requireAuthenticatedMutationSession;
|
||||||
const session = await requireAuthenticatedMutationSession(request);
|
|
||||||
if (session.user.mustChangePassword) {
|
|
||||||
throw new AuthError('password_change_required', '首次登录或密码重置后必须先修改密码。', 403);
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
};
|
|
||||||
|
|
||||||
const requireAdminSession = async (request: FastifyRequest): Promise<ActiveSession> => (
|
const requireAdminSession = async (request: FastifyRequest): Promise<ActiveSession> => (
|
||||||
requireAdmin(await getReadySession(request))
|
requireAdmin(await getSession(request))
|
||||||
);
|
);
|
||||||
|
|
||||||
const requireAdminMutationSession = async (request: FastifyRequest): Promise<ActiveSession> => (
|
const requireAdminMutationSession = async (request: FastifyRequest): Promise<ActiveSession> => (
|
||||||
@@ -571,7 +554,7 @@ export async function buildServer({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const requireLeadershipSession = async (request: FastifyRequest): Promise<ActiveSession> => (
|
const requireLeadershipSession = async (request: FastifyRequest): Promise<ActiveSession> => (
|
||||||
requireLeadership(await getReadySession(request))
|
requireLeadership(await getSession(request))
|
||||||
);
|
);
|
||||||
|
|
||||||
async function persistParseOutcome(
|
async function persistParseOutcome(
|
||||||
@@ -980,7 +963,6 @@ export async function buildServer({
|
|||||||
username: body.username,
|
username: body.username,
|
||||||
password: body.password,
|
password: body.password,
|
||||||
role: body.role,
|
role: body.role,
|
||||||
mustChangePassword: body.must_change_password,
|
|
||||||
businessRouteIds: body.business_route_ids
|
businessRouteIds: body.business_route_ids
|
||||||
}, requestId(request));
|
}, requestId(request));
|
||||||
return { ok: true, account };
|
return { ok: true, account };
|
||||||
@@ -1022,7 +1004,6 @@ export async function buildServer({
|
|||||||
session.user,
|
session.user,
|
||||||
userId,
|
userId,
|
||||||
body.password,
|
body.password,
|
||||||
body.must_change_password,
|
|
||||||
requestId(request)
|
requestId(request)
|
||||||
);
|
);
|
||||||
return { ok: true, password_reset: true, sessions_revoked: true };
|
return { ok: true, password_reset: true, sessions_revoked: true };
|
||||||
@@ -1172,7 +1153,7 @@ export async function buildServer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/tasks', async (request) => {
|
app.get('/api/tasks', async (request) => {
|
||||||
const session = await getReadySession(request);
|
const session = await getSession(request);
|
||||||
const query = listTasksQuerySchema.parse(request.query || {});
|
const query = listTasksQuerySchema.parse(request.query || {});
|
||||||
const page = await tasks.listTasksPage(session.user.organizationId, {
|
const page = await tasks.listTasksPage(session.user.organizationId, {
|
||||||
status: query.status || undefined,
|
status: query.status || undefined,
|
||||||
@@ -1224,19 +1205,19 @@ export async function buildServer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/tasks/:taskId', async (request) => {
|
app.get('/api/tasks/:taskId', async (request) => {
|
||||||
const session = await getReadySession(request);
|
const session = await getSession(request);
|
||||||
const params = request.params as { taskId: string };
|
const params = request.params as { taskId: string };
|
||||||
return { ok: true, task: await tasks.getTask(session.user.organizationId, params.taskId, contextFor(session, request)) };
|
return { ok: true, task: await tasks.getTask(session.user.organizationId, params.taskId, contextFor(session, request)) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/tasks/:taskId/input-history', async (request) => {
|
app.get('/api/tasks/:taskId/input-history', async (request) => {
|
||||||
const session = await getReadySession(request);
|
const session = await getSession(request);
|
||||||
const params = request.params as { taskId: string };
|
const params = request.params as { taskId: string };
|
||||||
return { ok: true, ...(await tasks.getTaskInputHistory(contextFor(session, request), params.taskId)) };
|
return { ok: true, ...(await tasks.getTaskInputHistory(contextFor(session, request), params.taskId)) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/tasks/:taskId/artifacts/:artifactId', async (request, reply) => {
|
app.get('/api/tasks/:taskId/artifacts/:artifactId', async (request, reply) => {
|
||||||
const session = await getReadySession(request);
|
const session = await getSession(request);
|
||||||
const params = request.params as { taskId: string; artifactId: string };
|
const params = request.params as { taskId: string; artifactId: string };
|
||||||
const artifactId = z.string().uuid().safeParse(params.artifactId);
|
const artifactId = z.string().uuid().safeParse(params.artifactId);
|
||||||
if (!artifactId.success) throw new TaskError('artifact_not_found', '附件不存在或无权访问。', 404);
|
if (!artifactId.success) throw new TaskError('artifact_not_found', '附件不存在或无权访问。', 404);
|
||||||
@@ -1403,7 +1384,7 @@ export async function buildServer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/events', async (request, reply) => {
|
app.get('/api/events', async (request, reply) => {
|
||||||
const session = await getReadySession(request);
|
const session = await getSession(request);
|
||||||
const query = (request.query || {}) as Record<string, unknown>;
|
const query = (request.query || {}) as Record<string, unknown>;
|
||||||
const querySince = Number(query.since || 0);
|
const querySince = Number(query.since || 0);
|
||||||
const reconnectSince = Number(request.headers['last-event-id'] || 0);
|
const reconnectSince = Number(request.headers['last-event-id'] || 0);
|
||||||
|
|||||||
@@ -67,14 +67,16 @@ test('account lifecycle is administrator-gated and protects passwords, sessions,
|
|||||||
assert.match(auth, /last_admin_protected/);
|
assert.match(auth, /last_admin_protected/);
|
||||||
assert.match(auth, /UPDATE sessions SET revoked_at = now\(\)/);
|
assert.match(auth, /UPDATE sessions SET revoked_at = now\(\)/);
|
||||||
assert.match(auth, /must_change_password = false/);
|
assert.match(auth, /must_change_password = false/);
|
||||||
|
assert.match(auth, /function validatePassword[\s\S]+if \(!password\)/);
|
||||||
|
assert.doesNotMatch(auth, /password\.length < 12|12—512/);
|
||||||
assert.match(auth, /account\.password_reset/);
|
assert.match(auth, /account\.password_reset/);
|
||||||
assert.match(auth, /account\.password_changed/);
|
assert.match(auth, /account\.password_changed/);
|
||||||
assert.match(server, /app\.get\('\/api\/accounts'[\s\S]+requireAdminSession\(request\)/);
|
assert.match(server, /app\.get\('\/api\/accounts'[\s\S]+requireAdminSession\(request\)/);
|
||||||
assert.match(server, /app\.post\('\/api\/accounts'[\s\S]+requireAdminMutationSession\(request\)/);
|
assert.match(server, /app\.post\('\/api\/accounts'[\s\S]+requireAdminMutationSession\(request\)/);
|
||||||
assert.match(server, /app\.get\('\/api\/audit'[\s\S]+requireAdminSession\(request\)/);
|
assert.match(server, /app\.get\('\/api\/audit'[\s\S]+requireAdminSession\(request\)/);
|
||||||
assert.match(server, /password_change_required/);
|
assert.doesNotMatch(server, /password_change_required|must_change_password|\.min\(12\)/);
|
||||||
const publicUser = server.slice(server.indexOf('function publicUser'), server.indexOf('async function loadExternalParser'));
|
const publicUser = server.slice(server.indexOf('function publicUser'), server.indexOf('async function loadExternalParser'));
|
||||||
assert.match(publicUser, /must_change_password/);
|
assert.doesNotMatch(publicUser, /must_change_password/);
|
||||||
assert.doesNotMatch(publicUser, /organization/);
|
assert.doesNotMatch(publicUser, /organization/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -213,6 +215,7 @@ test('operator UI exposes role-aware accounts, executive drill-through, original
|
|||||||
assert.match(index, /href="\/operations-dashboard"/);
|
assert.match(index, /href="\/operations-dashboard"/);
|
||||||
assert.match(index, /id="passwordChangeForm"/);
|
assert.match(index, /id="passwordChangeForm"/);
|
||||||
assert.match(index, /id="accountForm"/);
|
assert.match(index, /id="accountForm"/);
|
||||||
|
assert.doesNotMatch(index, /accountMustChangePassword|首次登录必须修改密码|minlength="12"|12—512/);
|
||||||
assert.match(index, /id="accountAuthorizationPanel"/);
|
assert.match(index, /id="accountAuthorizationPanel"/);
|
||||||
assert.match(index, /id="accountAuthorizationTypes"/);
|
assert.match(index, /id="accountAuthorizationTypes"/);
|
||||||
assert.match(index, /id="accountAuthorizationSave"/);
|
assert.match(index, /id="accountAuthorizationSave"/);
|
||||||
@@ -231,6 +234,7 @@ test('operator UI exposes role-aware accounts, executive drill-through, original
|
|||||||
assert.doesNotMatch(index, /OPERATIONS OVERVIEW|BUSINESS TRACE|指令操作历史/);
|
assert.doesNotMatch(index, /OPERATIONS OVERVIEW|BUSINESS TRACE|指令操作历史/);
|
||||||
assert.match(index, /id="historyArchiveInput"/);
|
assert.match(index, /id="historyArchiveInput"/);
|
||||||
assert.match(app, /authUser\?\.role === 'admin'/);
|
assert.match(app, /authUser\?\.role === 'admin'/);
|
||||||
|
assert.doesNotMatch(app, /passwordChangeForced|must_change_password/);
|
||||||
assert.match(app, /crypto\.randomUUID/);
|
assert.match(app, /crypto\.randomUUID/);
|
||||||
assert.match(app, /\/api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/input-history/);
|
assert.match(app, /\/api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/input-history/);
|
||||||
assert.match(app, /创建人与原始输入审计/);
|
assert.match(app, /创建人与原始输入审计/);
|
||||||
|
|||||||
Reference in new issue
Block a user