Files
Termix/src/backend/database/routes/user-admin-routes.ts
T

795 lines
24 KiB
TypeScript
Raw Normal View History

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";
+8
2026-07-19 12:29:52 -05:00
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
+2
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";
+8
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;
}
+8
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 {
+8
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
+2
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,
+8
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,
}
: {}),
+2
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 {
+8
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" });
}
+8
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" });
}
+8
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" });
}
+8
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 {
+8
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",
+8
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 {
+8
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",
+8
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,
+8
2026-07-19 12:29:52 -05:00
targetUserId: targetUser.id,
targetUsername: targetUser.username,
2026-06-04 15:16:53 -04:00
});
+2
2026-06-16 15:59:53 -05:00
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
+8
2026-07-19 12:29:52 -05:00
username: adminUser.username ?? userId,
+2
2026-06-16 15:59:53 -05:00
action: "make_admin",
resourceType: "user",
+8
2026-07-19 12:29:52 -05:00
resourceId: targetUser.id,
resourceName: targetUser.username,
+2
2026-06-16 15:59:53 -05:00
ipAddress,
userAgent,
success: true,
});
+8
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 {
+8
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 (
+8
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" });
}
+8
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" });
}
+8
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" });
}
+8
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 {
+8
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",
+8
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 {
+8
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",
+8
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,
+8
2026-07-19 12:29:52 -05:00
targetUserId: targetUser.id,
targetUsername: targetUser.username,
2026-06-04 15:16:53 -04:00
});
+2
2026-06-16 15:59:53 -05:00
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
+8
2026-07-19 12:29:52 -05:00
username: adminUser.username ?? userId,
+2
2026-06-16 15:59:53 -05:00
action: "remove_admin",
resourceType: "user",
+8
2026-07-19 12:29:52 -05:00
resourceId: targetUser.id,
resourceName: targetUser.username,
+2
2026-06-16 15:59:53 -05:00
ipAddress,
userAgent,
success: true,
});
2026-06-04 15:16:53 -04:00
res.json({
+8
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;
+8
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 {
+8
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 {
+8
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();
+8
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 {
+8
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) {
+8
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 {
+8
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,
+8
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" });
}
});
+8
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
}