66 * Usage:
77 * jss start [options] Start the server
88 * jss init Initialize configuration
9+ * jss passwd <username> Change user password
910 */
1011
1112import { Command } from 'commander' ;
@@ -14,6 +15,7 @@ import { loadConfig, saveConfig, printConfig, defaults } from '../src/config.js'
1415import { createInvite , listInvites , revokeInvite } from '../src/idp/invites.js' ;
1516import { setQuotaLimit , getQuotaInfo , reconcileQuota , formatBytes } from '../src/storage/quota.js' ;
1617import { parseSize } from '../src/config.js' ;
18+ import crypto from 'crypto' ;
1719import fs from 'fs-extra' ;
1820import path from 'path' ;
1921import { 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