import type { AuthenticatedRequest } from "../../../types/index.js"; import type { RequestHandler, Router } from "express"; import { authLogger } from "../../utils/logger.js"; import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; import bcrypt from "bcryptjs"; import { nanoid } from "nanoid"; import { AuthManager } from "../../utils/auth-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { createCurrentRoleRepository, createCurrentUserRepository, } from "../repositories/factory.js"; import type { UserRecord, UserRepository, } from "../repositories/user-repository.js"; function isNonEmptyString(val: unknown): val is string { return typeof val === "string" && val.trim().length > 0; } async function getUserByPreferredIdentifier( userRepository: UserRepository, userId: string | null, username: string | null, ): Promise { return userId ? userRepository.findById(userId) : userRepository.findByUsername(username!); } 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 { const userRepository = createCurrentUserRepository(); const requester = await userRepository.findById( (req as AuthenticatedRequest).userId, ); const allUsers = await userRepository.listAll(); 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, // 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, } : {}), })), }); } 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 { const userRepository = createCurrentUserRepository(); const adminUser = await userRepository.findById(userId); if (!adminUser?.isAdmin) { return res.status(403).json({ error: "Not authorized" }); } const targetUser = await getUserByPreferredIdentifier( userRepository, resolvedUserId, resolvedUsername, ); if (!targetUser) { return res.status(404).json({ error: "User not found" }); } if (targetUser.isAdmin) { return res.status(400).json({ error: "User is already an admin" }); } await userRepository.update(targetUser.id, { isAdmin: true }); try { await createCurrentRoleRepository().switchUserRoleName({ userId: targetUser.id, addRoleName: "admin", removeRoleName: "user", grantedBy: userId, }); } catch (roleError) { authLogger.error("Failed to sync admin role on make-admin", roleError, { operation: "make_admin_role_sync", userId: targetUser.id, }); } try { await DatabaseSaveTrigger.forceSave("make_admin_explicit_save"); } catch (saveError) { authLogger.error( "Failed to persist admin promotion to disk", saveError, { operation: "make_admin_save_failed", userId: targetUser.id, username: targetUser.username, }, ); } authLogger.info("Admin privileges granted", { operation: "admin_grant", adminId: userId, targetUserId: targetUser.id, targetUsername: targetUser.username, }); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId, username: adminUser.username ?? userId, action: "make_admin", resourceType: "user", resourceId: targetUser.id, resourceName: targetUser.username, ipAddress, userAgent, success: true, }); res.json({ message: `User ${targetUser.username} is now an admin` }); } 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 { const userRepository = createCurrentUserRepository(); const adminUser = await userRepository.findById(userId); if (!adminUser?.isAdmin) { return res.status(403).json({ error: "Not authorized" }); } if ( (resolvedUserId && adminUser.id === resolvedUserId) || (resolvedUsername && adminUser.username === resolvedUsername) ) { return res .status(400) .json({ error: "Cannot remove your own admin status" }); } const targetUser = await getUserByPreferredIdentifier( userRepository, resolvedUserId, resolvedUsername, ); if (!targetUser) { return res.status(404).json({ error: "User not found" }); } if (!targetUser.isAdmin) { return res.status(400).json({ error: "User is not an admin" }); } await userRepository.update(targetUser.id, { isAdmin: false }); try { await createCurrentRoleRepository().switchUserRoleName({ userId: targetUser.id, addRoleName: "user", removeRoleName: "admin", grantedBy: userId, }); } catch (roleError) { authLogger.error( "Failed to sync user role on remove-admin", roleError, { operation: "remove_admin_role_sync", userId: targetUser.id, }, ); } try { await DatabaseSaveTrigger.forceSave("remove_admin_explicit_save"); } catch (saveError) { authLogger.error("Failed to persist admin removal to disk", saveError, { operation: "remove_admin_save_failed", userId: targetUser.id, username: targetUser.username, }); } authLogger.info("Admin privileges revoked", { operation: "admin_revoke", adminId: userId, targetUserId: targetUser.id, targetUsername: targetUser.username, }); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId, username: adminUser.username ?? userId, action: "remove_admin", resourceType: "user", resourceId: targetUser.id, resourceName: targetUser.username, ipAddress, userAgent, success: true, }); res.json({ message: `Admin status removed from ${targetUser.username}`, }); } catch (err) { authLogger.error("Failed to remove admin status", err); res.status(500).json({ error: "Failed to remove admin status" }); } }); /** * @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; const userRepository = createCurrentUserRepository(); let adminUser: UserRecord | null = null; try { adminUser = await userRepository.findById(adminId); if (!adminUser?.isAdmin) { 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 { const existing = await userRepository.findByUsername(username); if (existing) { return res.status(409).json({ error: "Username already exists" }); } const password_hash = await bcrypt.hash(password, 10); const id = nanoid(); 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, }); try { await createCurrentRoleRepository().assignRoleNameToUser({ userId: id, roleName: "user", grantedBy: adminId, }); } 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) { await userRepository.delete(id); 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 { await DatabaseSaveTrigger.forceSave("admin_create_user_explicit_save"); } 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, username: adminUser.username ?? adminId, 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" }); } }); /** * @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" }); } }); }