Files
sencho/backend/src/cli/resetPassword.ts
T
Anso c6d1631afe feat(recovery): add safe-mode recovery surface and emergency CLI (#1286)
* feat(recovery): add safe-mode recovery surface and emergency CLI

Add a read-only Recovery tab under Settings (admin-only) backed by a new
GET /api/diagnostics endpoint reporting app version, database integrity,
encryption-key status, Docker reachability, account and SSO counts, and
non-secret configuration. The endpoint loads without Docker or live metrics
so it stays available when the dashboard does not, requires a genuine admin
session, and builds its config block from a non-secret allowlist so no
credentials are ever exposed.

Expand the emergency command-line toolkit beyond the two-factor reset with
seven host-level commands: reset-password, create-emergency-admin,
clear-sessions, disable-sso, diagnostics, validate-db, and backup-data. Each
prints its result, exits with a meaningful status code, and writes an audit
entry where it changes state.

Document the toolkit in a new operator guide and link it from the recovery
and two-factor pages.

* feat(recovery): download the emergency command reference as a text file

The recovery commands are needed exactly when the dashboard is unreachable,
so reading them only in-app is a chicken-and-egg problem. Add a Download
button to the command-line section that saves the full
`docker compose exec sencho ...` reference as a text file, letting operators
keep it on hand before they need it. Reuses a shared download helper with the
existing diagnostics export.

* fix(recovery): harden diagnostics, backup, and emergency-admin against edge cases

Address findings from an independent review of the recovery toolkit:

- DiagnosticsService now degrades instead of throwing when a queried table is
  missing or corrupt: each read falls back and is folded into database.ok, so a
  broken database reports "problem detected" rather than failing the whole
  endpoint or showing a misleading healthy state with zeroed counts.
- backup-data refuses a destination that resolves to the live database, which
  would otherwise report success while producing no separate copy.
- create-emergency-admin now applies the same username rule as the user-
  management route, extracted to a shared helper so both stay in sync.

Adds tests for a missing read table, a malformed emergency-admin username, and
the backup same-target rejection.
2026-06-02 16:11:24 -04:00

53 lines
2.1 KiB
TypeScript

/**
* Emergency CLI: reset a local user's password from a shell inside the
* container, used when the admin password is forgotten and no other admin can
* sign in to reset it from the UI.
*
* Run via:
* docker compose exec sencho node dist/cli/resetPassword.js <username> <new-password>
*
* Existing sessions for the target are invalidated by bumping `token_version`,
* and the reset is written to the audit log with actor `cli`.
*/
import bcrypt from 'bcrypt';
import { DatabaseService } from '../services/DatabaseService';
import { BCRYPT_SALT_ROUNDS } from '../helpers/constants';
import { auditCli, exitWith, usage, type CliResult } from './_shared';
const MIN_PASSWORD_LENGTH = 8;
export async function resetPassword(username: string, newPassword: string): Promise<CliResult> {
if (!username || typeof username !== 'string') {
return { ok: false, message: 'Username is required' };
}
if (!newPassword || newPassword.length < MIN_PASSWORD_LENGTH) {
return { ok: false, message: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` };
}
const db = DatabaseService.getInstance();
const user = db.getUserByUsername(username);
if (!user) {
return { ok: false, message: `User not found: ${username}` };
}
if (user.auth_provider !== 'local') {
return { ok: false, message: `User ${username} signs in via ${user.auth_provider}; password reset applies to local accounts only` };
}
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS);
db.updateUser(user.id, { password_hash: passwordHash });
db.bumpTokenVersion(user.id);
auditCli(db, `/cli/reset-password/${username}`, `CLI reset password for ${username}`);
return { ok: true, message: `Password reset for ${username}. Existing sessions were signed out.` };
}
async function main(): Promise<void> {
const username = process.argv[2];
const newPassword = process.argv[3];
if (!username || !newPassword) {
usage('Usage: node dist/cli/resetPassword.js <username> <new-password>');
}
exitWith(await resetPassword(username, newPassword));
}
if (require.main === module) {
void main();
}