Skip to content

Commit 3e1e84b

Browse files
Add jss passwd command for password management. Fixes JavaScriptSolidServer#232
1 parent 85a8836 commit 3e1e84b

1 file changed

Lines changed: 130 additions & 0 deletions

File tree

bin/jss.js

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* Usage:
77
* jss start [options] Start the server
88
* jss init Initialize configuration
9+
* jss passwd <username> Change user password
910
*/
1011

1112
import { Command } from 'commander';
@@ -14,6 +15,7 @@ import { loadConfig, saveConfig, printConfig, defaults } from '../src/config.js'
1415
import { createInvite, listInvites, revokeInvite } from '../src/idp/invites.js';
1516
import { setQuotaLimit, getQuotaInfo, reconcileQuota, formatBytes } from '../src/storage/quota.js';
1617
import { parseSize } from '../src/config.js';
18+
import crypto from 'crypto';
1719
import fs from 'fs-extra';
1820
import path from 'path';
1921
import { fileURLToPath } from 'url';
@@ -616,6 +618,134 @@ tokenCmd
616618
}
617619
});
618620

621+
/**
622+
* Passwd command - change a user's password
623+
*/
624+
program
625+
.command('passwd <username>')
626+
.description('Change password for a user account')
627+
.option('-p, --password <password>', 'New password (non-interactive)')
628+
.option('-g, --generate', 'Generate a random password')
629+
.option('-r, --root <path>', 'Data directory')
630+
.action(async (username, options) => {
631+
try {
632+
if (options.root) {
633+
process.env.DATA_ROOT = path.resolve(options.root);
634+
}
635+
636+
// Load account by username
637+
const dataRoot = process.env.DATA_ROOT || './data';
638+
const accountsDir = path.join(dataRoot, '.idp', 'accounts');
639+
const indexPath = path.join(accountsDir, '_username_index.json');
640+
641+
let usernameIndex;
642+
try {
643+
usernameIndex = await fs.readJson(indexPath);
644+
} catch (err) {
645+
if (err.code === 'ENOENT') {
646+
console.error(`Error: No accounts found (missing ${indexPath})`);
647+
process.exit(1);
648+
}
649+
throw err;
650+
}
651+
652+
const normalizedUsername = username.toLowerCase().trim();
653+
const accountId = usernameIndex[normalizedUsername];
654+
if (!accountId) {
655+
console.error(`Error: User not found: ${username}`);
656+
process.exit(1);
657+
}
658+
659+
const accountPath = path.join(accountsDir, `${accountId}.json`);
660+
const account = await fs.readJson(accountPath);
661+
662+
// Determine new password
663+
let newPassword;
664+
665+
if (options.generate) {
666+
newPassword = crypto.randomBytes(16).toString('base64url');
667+
} else if (options.password) {
668+
newPassword = options.password;
669+
} else {
670+
// Interactive prompt
671+
newPassword = await promptPassword('New password: ');
672+
const confirm = await promptPassword('Confirm password: ');
673+
if (newPassword !== confirm) {
674+
console.error('Error: Passwords do not match');
675+
process.exit(1);
676+
}
677+
}
678+
679+
if (!newPassword) {
680+
console.error('Error: Password cannot be empty');
681+
process.exit(1);
682+
}
683+
684+
// Hash and save
685+
const bcrypt = await import('bcryptjs').then(m => m.default);
686+
account.passwordHash = await bcrypt.hash(newPassword, 10);
687+
account.passwordChangedAt = new Date().toISOString();
688+
await fs.writeJson(accountPath, account, { spaces: 2 });
689+
690+
if (options.generate) {
691+
console.log(`\nPassword updated for ${normalizedUsername}`);
692+
console.log(`Generated password: ${newPassword}\n`);
693+
} else {
694+
console.log(`\nPassword updated for ${normalizedUsername}\n`);
695+
}
696+
} catch (err) {
697+
console.error(`Error: ${err.message}`);
698+
process.exit(1);
699+
}
700+
});
701+
702+
/**
703+
* Helper: Prompt for a password (hidden input)
704+
*/
705+
async function promptPassword(question) {
706+
const rl = readline.createInterface({
707+
input: process.stdin,
708+
output: process.stdout
709+
});
710+
711+
return new Promise((resolve) => {
712+
// Disable echo for password input
713+
if (process.stdin.isTTY) {
714+
process.stdout.write(` ${question}`);
715+
const stdin = process.openStdin();
716+
process.stdin.setRawMode(true);
717+
let password = '';
718+
const onData = (ch) => {
719+
const c = ch.toString('utf8');
720+
if (c === '\n' || c === '\r' || c === '\u0004') {
721+
process.stdin.setRawMode(false);
722+
process.stdin.removeListener('data', onData);
723+
process.stdout.write('\n');
724+
rl.close();
725+
resolve(password);
726+
} else if (c === '\u0003') {
727+
// Ctrl+C
728+
process.exit(0);
729+
} else if (c === '\u007f' || c === '\b') {
730+
// Backspace
731+
if (password.length > 0) {
732+
password = password.slice(0, -1);
733+
}
734+
} else {
735+
password += c;
736+
}
737+
};
738+
process.stdin.on('data', onData);
739+
} else {
740+
// Non-TTY: read line normally (piped input)
741+
rl.question(` ${question}`, (answer) => {
742+
rl.close();
743+
resolve(answer.trim());
744+
});
745+
}
746+
});
747+
}
748+
619749
/**
620750
* Helper: Prompt for input
621751
*/

0 commit comments

Comments
 (0)