2026-06-04 15:16:53 -04:00
import type { AuthenticatedRequest } from "../../../types/index.js" ;
import type { RequestHandler , Router } from "express" ;
import { authLogger } from "../../utils/logger.js" ;
2026-07-19 12:29:52 -05:00
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js" ;
2026-06-16 15:59:53 -05:00
import { logAudit , getRequestMeta } from "../../utils/audit-logger.js" ;
2026-06-21 15:55:41 -05:00
import bcrypt from "bcryptjs" ;
import { nanoid } from "nanoid" ;
import { AuthManager } from "../../utils/auth-manager.js" ;
2026-07-19 12:29:52 -05:00
import { DataCrypto } from "../../utils/data-crypto.js" ;
import {
createCurrentRoleRepository ,
createCurrentUserRepository ,
} from "../repositories/factory.js" ;
import type {
UserRecord ,
UserRepository ,
} from "../repositories/user-repository.js" ;
2026-06-04 15:16:53 -04:00
function isNonEmptyString ( val : unknown ) : val is string {
return typeof val === "string" && val . trim (). length > 0 ;
}
2026-07-19 12:29:52 -05:00
async function getUserByPreferredIdentifier (
userRepository : UserRepository ,
userId : string | null ,
username : string | null ,
) : Promise < UserRecord | null > {
return userId
? userRepository . findById ( userId )
: userRepository . findByUsername ( username ! );
}
2026-06-04 15:16:53 -04:00
export function registerUserAdminRoutes (
router : Router ,
authenticateJWT : RequestHandler ,
) : void {
/**
* @openapi
* /users/list:
* get:
* summary: List all users
* description: Retrieves a list of all users in the system.
* tags:
* - Users
* responses:
* 200:
* description: A list of users.
* 403:
* description: Not authorized.
* 500:
* description: Failed to list users.
*/
router . get ( "/list" , authenticateJWT , async ( req , res ) => {
try {
2026-07-19 12:29:52 -05:00
const userRepository = createCurrentUserRepository ();
const requester = await userRepository . findById (
( req as AuthenticatedRequest ). userId ,
);
const allUsers = await userRepository . listAll ();
2026-06-04 15:16:53 -04:00
2026-06-16 15:59:53 -05:00
res . json ({
users : allUsers.map (( u ) => ({
userId : u.id ,
username : u.username ,
is_admin : u.isAdmin ,
is_oidc : u.isOidc ,
password_hash : u.passwordHash ? "set" : null ,
2026-07-19 12:29:52 -05:00
// Management-only details stay admin-eyes-only; regular users hit
// this route to pick sharing targets.
...( requester ? . isAdmin
? {
data_unlocked : DataCrypto.canUserAccessData ( u . id ),
totp_enabled : !! u . totpEnabled ,
}
: {}),
2026-06-16 15:59:53 -05:00
})),
});
2026-06-04 15:16:53 -04:00
} catch ( err ) {
authLogger . error ( "Failed to list users" , err );
res . status ( 500 ). json ({ error : "Failed to list users" });
}
});
/**
* @openapi
* /users/make-admin:
* post:
* summary: Make user admin
* description: Grants admin privileges to a user.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* userId:
* type: string
* description: Preferred unique user identifier.
* username:
* type: string
* description: Legacy fallback identifier.
* responses:
* 200:
* description: User is now an admin.
* 400:
* description: User ID or username is required, or the user is already an admin.
* 403:
* description: Not authorized.
* 404:
* description: User not found.
* 500:
* description: Failed to make user admin.
*/
router . post ( "/make-admin" , authenticateJWT , async ( req , res ) => {
const userId = ( req as AuthenticatedRequest ). userId ;
const { userId : targetUserId , username } = req . body ;
const resolvedUserId = isNonEmptyString ( targetUserId )
? targetUserId . trim ()
: null ;
const resolvedUsername = isNonEmptyString ( username )
? username . trim ()
: null ;
if ( ! resolvedUserId && ! resolvedUsername ) {
return res . status ( 400 ). json ({ error : "User ID or username is required" });
}
try {
2026-07-19 12:29:52 -05:00
const userRepository = createCurrentUserRepository ();
const adminUser = await userRepository . findById ( userId );
if ( ! adminUser ? . isAdmin ) {
2026-06-04 15:16:53 -04:00
return res . status ( 403 ). json ({ error : "Not authorized" });
}
2026-07-19 12:29:52 -05:00
const targetUser = await getUserByPreferredIdentifier (
userRepository ,
resolvedUserId ,
resolvedUsername ,
);
if ( ! targetUser ) {
2026-06-04 15:16:53 -04:00
return res . status ( 404 ). json ({ error : "User not found" });
}
2026-07-19 12:29:52 -05:00
if ( targetUser . isAdmin ) {
2026-06-04 15:16:53 -04:00
return res . status ( 400 ). json ({ error : "User is already an admin" });
}
2026-07-19 12:29:52 -05:00
await userRepository . update ( targetUser . id , { isAdmin : true });
2026-06-04 15:16:53 -04:00
2026-06-21 15:55:41 -05:00
try {
2026-07-19 12:29:52 -05:00
await createCurrentRoleRepository (). switchUserRoleName ({
userId : targetUser.id ,
addRoleName : "admin" ,
removeRoleName : "user" ,
grantedBy : userId ,
});
2026-06-21 15:55:41 -05:00
} catch ( roleError ) {
authLogger . error ( "Failed to sync admin role on make-admin" , roleError , {
operation : "make_admin_role_sync" ,
2026-07-19 12:29:52 -05:00
userId : targetUser.id ,
2026-06-21 15:55:41 -05:00
});
}
2026-06-04 15:16:53 -04:00
try {
2026-07-19 12:29:52 -05:00
await DatabaseSaveTrigger . forceSave ( "make_admin_explicit_save" );
2026-06-04 15:16:53 -04:00
} catch ( saveError ) {
authLogger . error (
"Failed to persist admin promotion to disk" ,
saveError ,
{
operation : "make_admin_save_failed" ,
2026-07-19 12:29:52 -05:00
userId : targetUser.id ,
username : targetUser.username ,
2026-06-04 15:16:53 -04:00
},
);
}
authLogger . info ( "Admin privileges granted" , {
operation : "admin_grant" ,
adminId : userId ,
2026-07-19 12:29:52 -05:00
targetUserId : targetUser.id ,
targetUsername : targetUser.username ,
2026-06-04 15:16:53 -04:00
});
2026-06-16 15:59:53 -05:00
const { ipAddress , userAgent } = getRequestMeta ( req );
await logAudit ({
userId ,
2026-07-19 12:29:52 -05:00
username : adminUser.username ?? userId ,
2026-06-16 15:59:53 -05:00
action : "make_admin" ,
resourceType : "user" ,
2026-07-19 12:29:52 -05:00
resourceId : targetUser.id ,
resourceName : targetUser.username ,
2026-06-16 15:59:53 -05:00
ipAddress ,
userAgent ,
success : true ,
});
2026-07-19 12:29:52 -05:00
res . json ({ message : `User ${ targetUser . username } is now an admin` });
2026-06-04 15:16:53 -04:00
} catch ( err ) {
authLogger . error ( "Failed to make user admin" , err );
res . status ( 500 ). json ({ error : "Failed to make user admin" });
}
});
/**
* @openapi
* /users/remove-admin:
* post:
* summary: Remove admin status
* description: Revokes admin privileges from a user.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* userId:
* type: string
* description: Preferred unique user identifier.
* username:
* type: string
* description: Legacy fallback identifier.
* responses:
* 200:
* description: Admin status removed from user.
* 400:
* description: User ID or username is required, or cannot remove your own admin status.
* 403:
* description: Not authorized.
* 404:
* description: User not found.
* 500:
* description: Failed to remove admin status.
*/
router . post ( "/remove-admin" , authenticateJWT , async ( req , res ) => {
const userId = ( req as AuthenticatedRequest ). userId ;
const { userId : targetUserId , username } = req . body ;
const resolvedUserId = isNonEmptyString ( targetUserId )
? targetUserId . trim ()
: null ;
const resolvedUsername = isNonEmptyString ( username )
? username . trim ()
: null ;
if ( ! resolvedUserId && ! resolvedUsername ) {
return res . status ( 400 ). json ({ error : "User ID or username is required" });
}
try {
2026-07-19 12:29:52 -05:00
const userRepository = createCurrentUserRepository ();
const adminUser = await userRepository . findById ( userId );
if ( ! adminUser ? . isAdmin ) {
2026-06-04 15:16:53 -04:00
return res . status ( 403 ). json ({ error : "Not authorized" });
}
if (
2026-07-19 12:29:52 -05:00
( resolvedUserId && adminUser . id === resolvedUserId ) ||
( resolvedUsername && adminUser . username === resolvedUsername )
2026-06-04 15:16:53 -04:00
) {
return res
. status ( 400 )
. json ({ error : "Cannot remove your own admin status" });
}
2026-07-19 12:29:52 -05:00
const targetUser = await getUserByPreferredIdentifier (
userRepository ,
resolvedUserId ,
resolvedUsername ,
);
if ( ! targetUser ) {
2026-06-04 15:16:53 -04:00
return res . status ( 404 ). json ({ error : "User not found" });
}
2026-07-19 12:29:52 -05:00
if ( ! targetUser . isAdmin ) {
2026-06-04 15:16:53 -04:00
return res . status ( 400 ). json ({ error : "User is not an admin" });
}
2026-07-19 12:29:52 -05:00
await userRepository . update ( targetUser . id , { isAdmin : false });
2026-06-04 15:16:53 -04:00
2026-06-21 15:55:41 -05:00
try {
2026-07-19 12:29:52 -05:00
await createCurrentRoleRepository (). switchUserRoleName ({
userId : targetUser.id ,
addRoleName : "user" ,
removeRoleName : "admin" ,
grantedBy : userId ,
});
2026-06-21 15:55:41 -05:00
} catch ( roleError ) {
authLogger . error (
"Failed to sync user role on remove-admin" ,
roleError ,
{
operation : "remove_admin_role_sync" ,
2026-07-19 12:29:52 -05:00
userId : targetUser.id ,
2026-06-21 15:55:41 -05:00
},
);
}
2026-06-04 15:16:53 -04:00
try {
2026-07-19 12:29:52 -05:00
await DatabaseSaveTrigger . forceSave ( "remove_admin_explicit_save" );
2026-06-04 15:16:53 -04:00
} catch ( saveError ) {
authLogger . error ( "Failed to persist admin removal to disk" , saveError , {
operation : "remove_admin_save_failed" ,
2026-07-19 12:29:52 -05:00
userId : targetUser.id ,
username : targetUser.username ,
2026-06-04 15:16:53 -04:00
});
}
authLogger . info ( "Admin privileges revoked" , {
operation : "admin_revoke" ,
adminId : userId ,
2026-07-19 12:29:52 -05:00
targetUserId : targetUser.id ,
targetUsername : targetUser.username ,
2026-06-04 15:16:53 -04:00
});
2026-06-16 15:59:53 -05:00
const { ipAddress , userAgent } = getRequestMeta ( req );
await logAudit ({
userId ,
2026-07-19 12:29:52 -05:00
username : adminUser.username ?? userId ,
2026-06-16 15:59:53 -05:00
action : "remove_admin" ,
resourceType : "user" ,
2026-07-19 12:29:52 -05:00
resourceId : targetUser.id ,
resourceName : targetUser.username ,
2026-06-16 15:59:53 -05:00
ipAddress ,
userAgent ,
success : true ,
});
2026-06-04 15:16:53 -04:00
res . json ({
2026-07-19 12:29:52 -05:00
message : `Admin status removed from ${ targetUser . username } ` ,
2026-06-04 15:16:53 -04:00
});
} catch ( err ) {
authLogger . error ( "Failed to remove admin status" , err );
res . status ( 500 ). json ({ error : "Failed to remove admin status" });
}
});
2026-06-21 15:55:41 -05:00
/**
* @openapi
* /users/admin-create:
* post:
* summary: Admin create user
* description: Allows an admin to create a new user regardless of whether public registration is enabled.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* username:
* type: string
* password:
* type: string
* responses:
* 200:
* description: User created successfully.
* 400:
* description: Username and password are required.
* 403:
* description: Not authorized.
* 409:
* description: Username already exists.
* 500:
* description: Failed to create user.
*/
router . post ( "/admin-create" , authenticateJWT , async ( req , res ) => {
const adminId = ( req as AuthenticatedRequest ). userId ;
2026-07-19 12:29:52 -05:00
const userRepository = createCurrentUserRepository ();
let adminUser : UserRecord | null = null ;
2026-06-21 15:55:41 -05:00
try {
2026-07-19 12:29:52 -05:00
adminUser = await userRepository . findById ( adminId );
if ( ! adminUser ? . isAdmin ) {
2026-06-21 15:55:41 -05:00
return res . status ( 403 ). json ({ error : "Not authorized" });
}
} catch ( err ) {
authLogger . error ( "Failed to verify admin status" , err );
return res . status ( 500 ). json ({ error : "Failed to verify admin status" });
}
const { username , password } = req . body ;
if ( ! isNonEmptyString ( username ) || ! isNonEmptyString ( password )) {
return res
. status ( 400 )
. json ({ error : "Username and password are required" });
}
try {
2026-07-19 12:29:52 -05:00
const existing = await userRepository . findByUsername ( username );
if ( existing ) {
2026-06-21 15:55:41 -05:00
return res . status ( 409 ). json ({ error : "Username already exists" });
}
const password_hash = await bcrypt . hash ( password , 10 );
const id = nanoid ();
2026-07-19 12:29:52 -05:00
await userRepository . create ({
id ,
username ,
passwordHash : password_hash ,
isAdmin : false ,
isOidc : false ,
clientId : "" ,
clientSecret : "" ,
issuerUrl : "" ,
authorizationUrl : "" ,
tokenUrl : "" ,
identifierPath : "" ,
namePath : "" ,
scopes : "openid email profile" ,
totpSecret : null ,
totpEnabled : false ,
totpBackupCodes : null ,
});
2026-06-21 15:55:41 -05:00
try {
2026-07-19 12:29:52 -05:00
await createCurrentRoleRepository (). assignRoleNameToUser ({
userId : id ,
roleName : "user" ,
grantedBy : adminId ,
});
2026-06-21 15:55:41 -05:00
} catch ( roleError ) {
authLogger . error (
"Failed to assign default role during admin create" ,
roleError ,
{
operation : "admin_create_user_role" ,
userId : id ,
},
);
}
const authManager = AuthManager . getInstance ();
try {
await authManager . registerUser ( id , password );
} catch ( encryptionError ) {
2026-07-19 12:29:52 -05:00
await userRepository . delete ( id );
2026-06-21 15:55:41 -05:00
authLogger . error (
"Failed to setup user encryption during admin create, rolled back" ,
encryptionError ,
{ operation : "admin_create_user_encryption_failed" , userId : id },
);
return res . status ( 500 ). json ({
error : "Failed to setup user security - user creation cancelled" ,
});
}
try {
2026-07-19 12:29:52 -05:00
await DatabaseSaveTrigger . forceSave ( "admin_create_user_explicit_save" );
2026-06-21 15:55:41 -05:00
} catch ( saveError ) {
authLogger . error (
"Failed to persist admin-created user to disk" ,
saveError ,
{
operation : "admin_create_user_save_failed" ,
userId : id ,
},
);
}
authLogger . success ( "User created by admin" , {
operation : "admin_create_user_success" ,
adminId ,
userId : id ,
username ,
});
const { ipAddress , userAgent } = getRequestMeta ( req );
await logAudit ({
userId : adminId ,
2026-07-19 12:29:52 -05:00
username : adminUser.username ?? adminId ,
2026-06-21 15:55:41 -05:00
action : "create_user" ,
resourceType : "user" ,
resourceId : id ,
resourceName : username ,
ipAddress ,
userAgent ,
success : true ,
});
res . json ({
message : "User created" ,
toast : { type : "success" , message : `User created: ${ username } ` },
});
} catch ( err ) {
authLogger . error ( "Failed to admin-create user" , err );
res . status ( 500 ). json ({ error : "Failed to create user" });
}
});
2026-07-19 12:29:52 -05:00
/**
* @openapi
* /users/admin/reset-password:
* post:
* summary: Reset a user's password (admin only)
* description: >
* Resets another user's password. Data is preserved for users whose
* encryption key has been migrated to the system wrap. Users who never
* logged in since the encryption upgrade require confirmDataWipe,
* which deletes their encrypted data.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - newPassword
* properties:
* userId:
* type: string
* username:
* type: string
* newPassword:
* type: string
* confirmDataWipe:
* type: boolean
* responses:
* 200:
* description: Password reset; dataWiped indicates whether encrypted data was deleted.
* 400:
* description: Missing or invalid parameters.
* 403:
* description: Admin access required.
* 404:
* description: User not found.
* 409:
* description: Reset would wipe the user's data and confirmDataWipe was not set.
* 500:
* description: Failed to reset password.
*/
router . post ( "/admin/reset-password" , authenticateJWT , async ( req , res ) => {
const adminId = ( req as AuthenticatedRequest ). userId ;
const { userId : targetUserId , username , newPassword } = req . body ;
const resolvedUserId = isNonEmptyString ( targetUserId )
? targetUserId . trim ()
: null ;
const resolvedUsername = isNonEmptyString ( username )
? username . trim ()
: null ;
if ( ! resolvedUserId && ! resolvedUsername ) {
return res . status ( 400 ). json ({ error : "User ID or username is required" });
}
if ( ! isNonEmptyString ( newPassword )) {
return res . status ( 400 ). json ({ error : "New password is required" });
}
try {
const userRepository = createCurrentUserRepository ();
const adminUser = await userRepository . findById ( adminId );
if ( ! adminUser ? . isAdmin ) {
return res . status ( 403 ). json ({ error : "Admin access required" });
}
const targetUser = await getUserByPreferredIdentifier (
userRepository ,
resolvedUserId ,
resolvedUsername ,
);
if ( ! targetUser ) {
return res . status ( 404 ). json ({ error : "User not found" });
}
if ( targetUser . isOidc && ! targetUser . passwordHash ) {
return res . status ( 400 ). json ({
error : "This user authenticates through an external provider" ,
});
}
const { resetUserPassword } =
await import ( "./user-password-reset-routes.js" );
const outcome = await resetUserPassword ( AuthManager . getInstance (), {
userId : targetUser.id ,
username : targetUser.username ,
newPassword ,
confirmDataWipe : req.body?.confirmDataWipe === true ,
});
if ( outcome . status === "wipe_confirmation_required" ) {
return res . status ( 409 ). json ({
error :
"This user has not logged in since the encryption upgrade, so their data cannot be recovered. Set confirmDataWipe to reset anyway and delete their hosts, credentials and snippets." ,
code : "DATA_WIPE_REQUIRED" ,
});
}
const { ipAddress , userAgent } = getRequestMeta ( req );
await logAudit ({
userId : adminId ,
username : adminUser.username ?? adminId ,
action : "admin_reset_password" ,
resourceType : "user" ,
resourceId : targetUser.id ,
resourceName : targetUser.username ,
ipAddress ,
userAgent ,
success : true ,
});
await DatabaseSaveTrigger . forceSave ( "admin_password_reset" );
res . json ({
message : "Password reset" ,
dataWiped : outcome.dataWiped ,
});
} catch ( err ) {
authLogger . error ( "Failed to reset user password" , err );
res . status ( 500 ). json ({ error : "Failed to reset password" });
}
});
/**
* @openapi
* /users/admin/totp/disable:
* post:
* summary: Disable a user's TOTP (admin only)
* description: Clears another user's TOTP secret, enabled flag and backup codes so they can log in without 2FA.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* userId:
* type: string
* responses:
* 200:
* description: TOTP disabled for the user.
* 400:
* description: User ID is required or TOTP is not enabled.
* 403:
* description: Admin access required.
* 404:
* description: User not found.
* 500:
* description: Failed to disable TOTP.
*/
router . post ( "/admin/totp/disable" , authenticateJWT , async ( req , res ) => {
const adminId = ( req as AuthenticatedRequest ). userId ;
const { userId : targetUserId } = req . body ;
if ( ! isNonEmptyString ( targetUserId )) {
return res . status ( 400 ). json ({ error : "User ID is required" });
}
try {
const userRepository = createCurrentUserRepository ();
const adminUser = await userRepository . findById ( adminId );
if ( ! adminUser ? . isAdmin ) {
return res . status ( 403 ). json ({ error : "Admin access required" });
}
const targetUser = await userRepository . findById ( targetUserId . trim ());
if ( ! targetUser ) {
return res . status ( 404 ). json ({ error : "User not found" });
}
if ( ! targetUser . totpEnabled ) {
return res
. status ( 400 )
. json ({ error : "TOTP is not enabled for this user" });
}
await userRepository . update ( targetUser . id , {
totpSecret : null ,
totpEnabled : false ,
totpBackupCodes : null ,
});
try {
await DatabaseSaveTrigger . forceSave ( "admin_disable_totp" );
} catch ( saveError ) {
authLogger . error ( "Failed to persist TOTP disable to disk" , saveError , {
operation : "admin_disable_totp_save_failed" ,
userId : targetUser.id ,
});
}
const { ipAddress , userAgent } = getRequestMeta ( req );
await logAudit ({
userId : adminId ,
username : adminUser.username ?? adminId ,
action : "admin_disable_totp" ,
resourceType : "user" ,
resourceId : targetUser.id ,
resourceName : targetUser.username ,
ipAddress ,
userAgent ,
success : true ,
});
res . json ({ message : "TOTP disabled" });
} catch ( err ) {
authLogger . error ( "Failed to disable TOTP for user" , err );
res . status ( 500 ). json ({ error : "Failed to disable TOTP" });
}
});
/**
* @openapi
* /users/admin/export/{userId}:
* get:
* summary: Export a user's data (admin only)
* description: Downloads a JSON export of another user's data (hosts, credentials, file manager bookmarks). Secrets are decrypted server-side, so handle the file carefully.
* tags:
* - Users
* parameters:
* - in: path
* name: userId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: JSON export of the user's data.
* 403:
* description: Admin access required.
* 404:
* description: User not found.
* 423:
* description: The user's data stays locked until their next login.
* 500:
* description: Failed to export user data.
*/
router . get ( "/admin/export/:userId" , authenticateJWT , async ( req , res ) => {
const adminId = ( req as AuthenticatedRequest ). userId ;
const targetUserId = Array . isArray ( req . params . userId )
? req . params . userId [ 0 ]
: req . params . userId ;
try {
const userRepository = createCurrentUserRepository ();
const adminUser = await userRepository . findById ( adminId );
if ( ! adminUser ? . isAdmin ) {
return res . status ( 403 ). json ({ error : "Admin access required" });
}
const targetUser = await userRepository . findById ( targetUserId );
if ( ! targetUser ) {
return res . status ( 404 ). json ({ error : "User not found" });
}
if ( ! DataCrypto . canUserAccessData ( targetUser . id )) {
return res . status ( 423 ). json ({
error : "Target user's data stays locked until their next login" ,
code : "TARGET_DATA_LOCKED" ,
});
}
const { UserDataExport } =
await import ( "../../utils/user-data-export.js" );
const exportData = await UserDataExport . exportUserData ( targetUser . id , {
format : "plaintext" ,
includeCredentials : true ,
});
const { ipAddress , userAgent } = getRequestMeta ( req );
await logAudit ({
userId : adminId ,
username : adminUser.username ?? adminId ,
action : "admin_export_user_data" ,
resourceType : "user" ,
resourceId : targetUser.id ,
resourceName : targetUser.username ,
ipAddress ,
userAgent ,
success : true ,
});
res . setHeader ( "Content-Type" , "application/json" );
res . setHeader (
"Content-Disposition" ,
`attachment; filename="termix-user- ${ targetUser . username } -export.json"` ,
);
res . json ( exportData );
} catch ( err ) {
authLogger . error ( "Failed to export user data" , err );
res . status ( 500 ). json ({ error : "Failed to export user data" });
}
});
2026-06-04 15:16:53 -04:00
}