import { isExternalAccount } from "../../auth/external-account.js"; 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, createCurrentUserAuthRepository, createCurrentUserRepository, } from "../repositories/factory.js"; import { listUserSecondFactors, resetUserSecondFactors, } from "../../auth/second-factor-admin.js"; import { isUsernameTaken, type UserRecord, type 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 users * description: > * Retrieves users in the system. Without `limit` the full list is * returned, which is what the sharing pickers rely on. Pass `limit` * (and optionally `offset`/`search`) to page through large directories. * tags: * - Users * parameters: * - in: query * name: search * required: false * schema: * type: string * description: Case-insensitive username substring filter. * - in: query * name: limit * required: false * schema: * type: integer * maximum: 500 * description: Page size. Omit to return every user. * - in: query * name: offset * required: false * schema: * type: integer * description: Number of users to skip. Requires `limit`. * responses: * 200: * description: A list of users, with the total matching count. * 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 query = req.query ?? {}; const search = typeof query.search === "string" ? query.search : undefined; const rawLimit = Number(query.limit); // Paging is opt-in: the share pickers fetch the whole list and filter it // client-side, so a request without ?limit keeps the original behaviour. const paginated = Number.isFinite(rawLimit) && rawLimit > 0; const limit = paginated ? Math.min(Math.floor(rawLimit), 500) : 0; const rawOffset = Number(query.offset); const offset = Number.isFinite(rawOffset) && rawOffset > 0 ? Math.floor(rawOffset) : 0; let pageUsers: UserRecord[]; let total: number; if (paginated) { const page = await userRepository.listPage({ search, limit, offset }); pageUsers = page.users; total = page.total; } else { const all = await userRepository.listAll(); const term = search?.trim().toLowerCase(); pageUsers = term ? all.filter((u) => u.username?.toLowerCase().includes(term)) : all; total = pageUsers.length; } const secondFactorUsers = requester?.isAdmin ? await createCurrentUserAuthRepository().listUserIdsWithSecondFactors() : new Set(); res.json({ users: pageUsers.map((u) => ({ userId: u.id, username: u.username, is_admin: u.isAdmin, is_external: isExternalAccount(u), 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), second_factor_enabled: secondFactorUsers.has(u.id), } : {}), })), total, ...(paginated ? { limit, offset } : {}), }); } 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, }); 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) { if (isUsernameTaken(err)) { return res.status(409).json({ error: "Username already exists" }); } 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.isExternal && !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 other saved data.", 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/{userId}/second-factors: * get: * summary: List a user's second factors (admin only) * description: Every second factor the user is enrolled in, and whether the plugin behind it is running. * tags: * - Users * parameters: * - in: path * name: userId * required: true * schema: * type: string * responses: * 200: * description: Enrolled factors. * 403: * description: Admin access required. * 404: * description: User not found. */ router.get( "/admin/:userId/second-factors", authenticateJWT, async (req, res) => { const adminId = (req as AuthenticatedRequest).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( String(req.params.userId), ); if (!targetUser) { return res.status(404).json({ error: "User not found" }); } res.json({ factors: await listUserSecondFactors(targetUser.id) }); } catch (err) { authLogger.error("Failed to list second factors", err); res.status(500).json({ error: "Failed to list second factors" }); } }, ); /** * @openapi * /users/admin/{userId}/second-factors: * delete: * summary: Reset a user's second factors (admin only) * description: Removes every second factor the user enrolled in, including ones whose plugin is disabled or missing, and forgets their trusted devices. The user can sign in with their first factor and enrol again. * tags: * - Users * parameters: * - in: path * name: userId * required: true * schema: * type: string * responses: * 200: * description: Second factors reset. * 403: * description: Admin access required. * 404: * description: User not found. */ router.delete( "/admin/:userId/second-factors", authenticateJWT, async (req, res) => { const adminId = (req as AuthenticatedRequest).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( String(req.params.userId), ); if (!targetUser) { return res.status(404).json({ error: "User not found" }); } const removed = await resetUserSecondFactors(targetUser.id); try { await DatabaseSaveTrigger.forceSave("admin_reset_second_factors"); } catch (saveError) { authLogger.error("Failed to persist second factor reset", saveError, { operation: "admin_reset_second_factors_save_failed", userId: targetUser.id, }); } const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId: adminId, username: adminUser.username ?? adminId, action: "admin_reset_second_factors", resourceType: "user", resourceId: targetUser.id, resourceName: targetUser.username, details: JSON.stringify({ removed }), ipAddress, userAgent, success: true, }); res.json({ message: "Second factors reset", removed }); } catch (err) { authLogger.error("Failed to reset second factors", err); res.status(500).json({ error: "Failed to reset second factors" }); } }, ); /** * @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" }); } }); }