From d9e56d8c54ff3a48be43f83df7856fa443deadd0 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Wed, 22 Apr 2026 06:12:11 +0200 Subject: [PATCH] CLI: add 'jss account delete' + refactor 'passwd' to use accounts.js (#292) Two related changes: 1. New `jss account delete ` subcommand. Calls accounts.deleteAccount() (the existing internal API) which already prunes the username/email/webId indexes and removes the account JSON. Adds a --yes flag (skip confirm prompt) and an opt-in --purge flag (also remove pod data at //). Without --purge: account record removed, pod data preserved. With --purge: fully clean, username can be re-registered. The --purge flag is opt-in because account deletion revokes identity (recoverable), but pod data removal is destructive (irreversible). Operators choose the level of cleanup. 2. Refactor `jss passwd ` to use the same accounts.js wrappers (findByUsername + updatePassword) instead of duplicating the index lookup and bcrypt hashing inline. Same external behaviour, ~40 fewer lines, one source of truth for the hashing parameters. Manual smoke test against a fresh --idp server: register / delete / re-register works; --purge frees the username; plain delete preserves pod data; passwd still works post-refactor. Existing 398-test suite passes (CLI commands aren't unit-tested anywhere in the repo today). Fixes #292 --- bin/jss.js | 89 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 58 insertions(+), 31 deletions(-) diff --git a/bin/jss.js b/bin/jss.js index df07edf4..5e32f6aa 100755 --- a/bin/jss.js +++ b/bin/jss.js @@ -13,6 +13,7 @@ import { Command } from 'commander'; import { createServer } from '../src/server.js'; import { loadConfig, saveConfig, printConfig, defaults } from '../src/config.js'; import { createInvite, listInvites, revokeInvite } from '../src/idp/invites.js'; +import { findByUsername, updatePassword, deleteAccount } from '../src/idp/accounts.js'; import { setQuotaLimit, getQuotaInfo, reconcileQuota, formatBytes } from '../src/storage/quota.js'; import { parseSize } from '../src/config.js'; import crypto from 'crypto'; @@ -637,32 +638,12 @@ program process.env.DATA_ROOT = path.resolve(options.root); } - // Load account by username - const dataRoot = process.env.DATA_ROOT || './data'; - const accountsDir = path.join(dataRoot, '.idp', 'accounts'); - const indexPath = path.join(accountsDir, '_username_index.json'); - - let usernameIndex; - try { - usernameIndex = await fs.readJson(indexPath); - } catch (err) { - if (err.code === 'ENOENT') { - console.error(`Error: No accounts found (missing ${indexPath})`); - process.exit(1); - } - throw err; - } - - const normalizedUsername = username.toLowerCase().trim(); - const accountId = usernameIndex[normalizedUsername]; - if (!accountId) { + const account = await findByUsername(username); + if (!account) { console.error(`Error: User not found: ${username}`); process.exit(1); } - const accountPath = path.join(accountsDir, `${accountId}.json`); - const account = await fs.readJson(accountPath); - // Determine new password let newPassword; @@ -673,8 +654,8 @@ program } else { // Interactive prompt newPassword = await promptPassword('New password: '); - const confirm = await promptPassword('Confirm password: '); - if (newPassword !== confirm) { + const confirmation = await promptPassword('Confirm password: '); + if (newPassword !== confirmation) { console.error('Error: Passwords do not match'); process.exit(1); } @@ -685,17 +666,63 @@ program process.exit(1); } - // Hash and save - const bcrypt = await import('bcryptjs').then(m => m.default); - account.passwordHash = await bcrypt.hash(newPassword, 10); - account.passwordChangedAt = new Date().toISOString(); - await fs.writeJson(accountPath, account, { spaces: 2 }); + await updatePassword(account.id, newPassword); if (options.generate) { - console.log(`\nPassword updated for ${normalizedUsername}`); + console.log(`\nPassword updated for ${account.username}`); console.log(`Generated password: ${newPassword}\n`); } else { - console.log(`\nPassword updated for ${normalizedUsername}\n`); + console.log(`\nPassword updated for ${account.username}\n`); + } + } catch (err) { + console.error(`Error: ${err.message}`); + process.exit(1); + } + }); + +/** + * Account commands - manage user accounts + */ +const accountCmd = program + .command('account') + .description('Manage user accounts'); + +accountCmd + .command('delete ') + .description('Delete a user account from the IdP') + .option('-r, --root ', 'Data directory') + .option('-y, --yes', 'Skip the confirmation prompt') + .option('--purge', 'Also delete pod data at //') + .action(async (username, options) => { + try { + if (options.root) { + process.env.DATA_ROOT = path.resolve(options.root); + } + + const account = await findByUsername(username); + if (!account) { + console.error(`Error: User not found: ${username}`); + process.exit(1); + } + + if (!options.yes) { + const summary = `Delete account '${account.username}' (${account.webId})${options.purge ? ' AND purge pod data' : ''}?`; + const ok = await confirm(summary, false); + if (!ok) { + console.log('Cancelled.'); + process.exit(0); + } + } + + await deleteAccount(account.id); + + if (options.purge) { + const dataRoot = process.env.DATA_ROOT || './data'; + const podPath = path.join(dataRoot, account.username); + await fs.remove(podPath); + console.log(`\nDeleted account ${account.username}. Pod data removed from ${podPath}.\n`); + } else { + console.log(`\nDeleted account ${account.username}. Pod data preserved at /${account.username}/ (use --purge to remove).\n`); } } catch (err) { console.error(`Error: ${err.message}`);