From 8fa093ae60b96df62373e0cac7104d32b3ea2735 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 14 Jan 2026 14:48:58 -0600 Subject: [PATCH 1/3] feat: re-added missing users.ts route from merge --- src/backend/database/routes/users.ts | 1055 ++++++++++++++++++++++++++ 1 file changed, 1055 insertions(+) diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 4ae24f40..5e7e099e 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -2490,6 +2490,321 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => { } }); +/** + * @openapi + * /users/totp/setup: + * post: + * summary: Setup TOTP + * description: Initiates TOTP setup by generating a secret and QR code. + * tags: + * - Users + * responses: + * 200: + * description: TOTP setup initiated with secret and QR code. + * 400: + * description: TOTP is already enabled. + * 404: + * description: User not found. + * 500: + * description: Failed to setup TOTP. + */ +router.post("/totp/setup", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({ error: "User not found" }); + } + + const userRecord = user[0]; + + if (userRecord.totp_enabled) { + return res.status(400).json({ error: "TOTP is already enabled" }); + } + + const secret = speakeasy.generateSecret({ + name: `Termix (${userRecord.username})`, + length: 32, + }); + + await db + .update(users) + .set({ totp_secret: secret.base32 }) + .where(eq(users.id, userId)); + + const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url || ""); + + res.json({ + secret: secret.base32, + qr_code: qrCodeUrl, + }); + } catch (err) { + authLogger.error("Failed to setup TOTP", err); + res.status(500).json({ error: "Failed to setup TOTP" }); + } +}); + +/** + * @openapi + * /users/totp/enable: + * post: + * summary: Enable TOTP + * description: Enables TOTP after verifying the initial code. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * totp_code: + * type: string + * responses: + * 200: + * description: TOTP enabled successfully with backup codes. + * 400: + * description: TOTP code is required or TOTP already enabled. + * 401: + * description: Invalid TOTP code. + * 404: + * description: User not found. + * 500: + * description: Failed to enable TOTP. + */ +router.post("/totp/enable", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + const { totp_code } = req.body; + + if (!totp_code) { + return res.status(400).json({ error: "TOTP code is required" }); + } + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({ error: "User not found" }); + } + + const userRecord = user[0]; + + if (userRecord.totp_enabled) { + return res.status(400).json({ error: "TOTP is already enabled" }); + } + + if (!userRecord.totp_secret) { + return res.status(400).json({ error: "TOTP setup not initiated" }); + } + + const verified = speakeasy.totp.verify({ + secret: userRecord.totp_secret, + encoding: "base32", + token: totp_code, + window: 2, + }); + + if (!verified) { + return res.status(401).json({ error: "Invalid TOTP code" }); + } + + const backupCodes = Array.from({ length: 8 }, () => + Math.random().toString(36).substring(2, 10).toUpperCase(), + ); + + await db + .update(users) + .set({ + totp_enabled: true, + totp_backup_codes: JSON.stringify(backupCodes), + }) + .where(eq(users.id, userId)); + + res.json({ + message: "TOTP enabled successfully", + backup_codes: backupCodes, + }); + } catch (err) { + authLogger.error("Failed to enable TOTP", err); + res.status(500).json({ error: "Failed to enable TOTP" }); + } +}); + +/** + * @openapi + * /users/totp/disable: + * post: + * summary: Disable TOTP + * description: Disables TOTP for a user. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * password: + * type: string + * totp_code: + * type: string + * responses: + * 200: + * description: TOTP disabled successfully. + * 400: + * description: Password or TOTP code is required. + * 401: + * description: Incorrect password or invalid TOTP code. + * 404: + * description: User not found. + * 500: + * description: Failed to disable TOTP. + */ +router.post("/totp/disable", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + const { password, totp_code } = req.body; + + if (!password && !totp_code) { + return res.status(400).json({ error: "Password or TOTP code is required" }); + } + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({ error: "User not found" }); + } + + const userRecord = user[0]; + + if (!userRecord.totp_enabled) { + return res.status(400).json({ error: "TOTP is not enabled" }); + } + + if (password && !userRecord.is_oidc) { + const isMatch = await bcrypt.compare(password, userRecord.password_hash); + if (!isMatch) { + return res.status(401).json({ error: "Incorrect password" }); + } + } else if (totp_code) { + const verified = speakeasy.totp.verify({ + secret: userRecord.totp_secret!, + encoding: "base32", + token: totp_code, + window: 2, + }); + + if (!verified) { + return res.status(401).json({ error: "Invalid TOTP code" }); + } + } else { + return res.status(400).json({ error: "Authentication required" }); + } + + await db + .update(users) + .set({ + totp_enabled: false, + totp_secret: null, + totp_backup_codes: null, + }) + .where(eq(users.id, userId)); + + res.json({ message: "TOTP disabled successfully" }); + } catch (err) { + authLogger.error("Failed to disable TOTP", err); + res.status(500).json({ error: "Failed to disable TOTP" }); + } +}); + +/** + * @openapi + * /users/totp/backup-codes: + * post: + * summary: Generate new backup codes + * description: Generates new TOTP backup codes. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * password: + * type: string + * totp_code: + * type: string + * responses: + * 200: + * description: New backup codes generated. + * 400: + * description: Password or TOTP code is required. + * 401: + * description: Incorrect password or invalid TOTP code. + * 404: + * description: User not found. + * 500: + * description: Failed to generate backup codes. + */ +router.post("/totp/backup-codes", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + const { password, totp_code } = req.body; + + if (!password && !totp_code) { + return res.status(400).json({ error: "Password or TOTP code is required" }); + } + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({ error: "User not found" }); + } + + const userRecord = user[0]; + + if (!userRecord.totp_enabled) { + return res.status(400).json({ error: "TOTP is not enabled" }); + } + + if (password && !userRecord.is_oidc) { + const isMatch = await bcrypt.compare(password, userRecord.password_hash); + if (!isMatch) { + return res.status(401).json({ error: "Incorrect password" }); + } + } else if (totp_code) { + const verified = speakeasy.totp.verify({ + secret: userRecord.totp_secret!, + encoding: "base32", + token: totp_code, + window: 2, + }); + + if (!verified) { + return res.status(401).json({ error: "Invalid TOTP code" }); + } + } else { + return res.status(400).json({ error: "Authentication required" }); + } + + const backupCodes = Array.from({ length: 8 }, () => + Math.random().toString(36).substring(2, 10).toUpperCase(), + ); + + await db + .update(users) + .set({ totp_backup_codes: JSON.stringify(backupCodes) }) + .where(eq(users.id, userId)); + + res.json({ backup_codes: backupCodes }); + } catch (err) { + authLogger.error("Failed to generate backup codes", err); + res.status(500).json({ error: "Failed to generate backup codes" }); + } +}); + /** * @openapi * /users/totp/verify-login: @@ -2657,4 +2972,744 @@ router.post("/totp/verify-login", async (req, res) => { } }); +/** + * @openapi + * /users/delete-user: + * delete: + * summary: Delete user (admin only) + * description: Allows an admin to delete another user and all related data. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * username: + * type: string + * responses: + * 200: + * description: User deleted successfully. + * 400: + * description: Username is required or cannot delete yourself. + * 403: + * description: Not authorized or cannot delete last admin. + * 404: + * description: User not found. + * 500: + * description: Failed to delete user. + */ +router.delete("/delete-user", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + const { username } = req.body; + + if (!isNonEmptyString(username)) { + return res.status(400).json({ error: "Username is required" }); + } + + try { + const adminUser = await db.select().from(users).where(eq(users.id, userId)); + if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { + return res.status(403).json({ error: "Not authorized" }); + } + + if (adminUser[0].username === username) { + return res.status(400).json({ error: "Cannot delete your own account" }); + } + + const targetUser = await db + .select() + .from(users) + .where(eq(users.username, username)); + if (!targetUser || targetUser.length === 0) { + return res.status(404).json({ error: "User not found" }); + } + + if (targetUser[0].is_admin) { + const adminCount = db.$client + .prepare("SELECT COUNT(*) as count FROM users WHERE is_admin = 1") + .get(); + if (((adminCount as { count?: number })?.count || 0) <= 1) { + return res + .status(403) + .json({ error: "Cannot delete the last admin user" }); + } + } + + const targetUserId = targetUser[0].id; + + await deleteUserAndRelatedData(targetUserId); + + authLogger.success( + `User ${username} deleted by admin ${adminUser[0].username}`, + ); + res.json({ message: `User ${username} deleted successfully` }); + } catch (err) { + authLogger.error("Failed to delete user", err); + + if (err && typeof err === "object" && "code" in err) { + if (err.code === "SQLITE_CONSTRAINT_FOREIGNKEY") { + res.status(400).json({ + error: + "Cannot delete user: User has associated data that cannot be removed", + }); + } else { + res.status(500).json({ error: `Database error: ${err.code}` }); + } + } else { + res.status(500).json({ error: "Failed to delete account" }); + } + } +}); + +/** + * @openapi + * /users/unlock-data: + * post: + * summary: Unlock user data + * description: Re-authenticates user with password to unlock encrypted data. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * password: + * type: string + * responses: + * 200: + * description: Data unlocked successfully. + * 400: + * description: Password is required. + * 401: + * description: Invalid password. + * 500: + * description: Failed to unlock data. + */ +router.post("/unlock-data", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + const { password } = req.body; + + if (!password) { + return res.status(400).json({ error: "Password is required" }); + } + + try { + const unlocked = await authManager.authenticateUser(userId, password); + if (unlocked) { + res.json({ + success: true, + message: "Data unlocked successfully", + }); + } else { + authLogger.warn("Failed to unlock user data - invalid password", { + operation: "user_data_unlock_failed", + userId, + }); + res.status(401).json({ error: "Invalid password" }); + } + } catch (err) { + authLogger.error("Data unlock failed", err, { + operation: "user_data_unlock_error", + userId, + }); + res.status(500).json({ error: "Failed to unlock data" }); + } +}); + +/** + * @openapi + * /users/data-status: + * get: + * summary: Check user data unlock status + * description: Checks if user data is currently unlocked. + * tags: + * - Users + * responses: + * 200: + * description: Data status returned. + * 500: + * description: Failed to check data status. + */ +router.get("/data-status", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + + try { + res.json({ + unlocked: true, + message: "Data is unlocked", + }); + } catch (err) { + authLogger.error("Failed to check data status", err, { + operation: "data_status_check_failed", + userId, + }); + res.status(500).json({ error: "Failed to check data status" }); + } +}); + +/** + * @openapi + * /users/sessions: + * get: + * summary: Get sessions + * description: Retrieves all sessions for authenticated user (or all sessions for admins). + * tags: + * - Users + * responses: + * 200: + * description: Sessions list returned. + * 404: + * description: User not found. + * 500: + * description: Failed to get sessions. + */ +router.get("/sessions", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({ error: "User not found" }); + } + + const userRecord = user[0]; + let sessionList; + + if (userRecord.is_admin) { + sessionList = await authManager.getAllSessions(); + + const enrichedSessions = await Promise.all( + sessionList.map(async (session) => { + const sessionUser = await db + .select({ username: users.username }) + .from(users) + .where(eq(users.id, session.userId)) + .limit(1); + + return { + ...session, + username: sessionUser[0]?.username || "Unknown", + }; + }), + ); + + return res.json({ sessions: enrichedSessions }); + } else { + sessionList = await authManager.getUserSessions(userId); + return res.json({ sessions: sessionList }); + } + } catch (err) { + authLogger.error("Failed to get sessions", err); + res.status(500).json({ error: "Failed to get sessions" }); + } +}); + +/** + * @openapi + * /users/sessions/{sessionId}: + * delete: + * summary: Revoke a specific session + * description: Revokes a specific session by ID. + * tags: + * - Users + * parameters: + * - in: path + * name: sessionId + * required: true + * schema: + * type: string + * description: The session ID to revoke + * responses: + * 200: + * description: Session revoked successfully. + * 400: + * description: Session ID is required. + * 403: + * description: Not authorized to revoke this session. + * 404: + * description: Session not found. + * 500: + * description: Failed to revoke session. + */ +router.delete("/sessions/:sessionId", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + const { sessionId } = req.params; + + if (!sessionId) { + return res.status(400).json({ error: "Session ID is required" }); + } + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({ error: "User not found" }); + } + + const userRecord = user[0]; + + const sessionRecords = await db + .select() + .from(sessions) + .where(eq(sessions.id, sessionId)) + .limit(1); + + if (sessionRecords.length === 0) { + return res.status(404).json({ error: "Session not found" }); + } + + const session = sessionRecords[0]; + + if (!userRecord.is_admin && session.userId !== userId) { + return res + .status(403) + .json({ error: "Not authorized to revoke this session" }); + } + + const success = await authManager.revokeSession(sessionId); + + if (success) { + authLogger.success("Session revoked", { + operation: "session_revoke", + sessionId, + revokedBy: userId, + sessionUserId: session.userId, + }); + res.json({ success: true, message: "Session revoked successfully" }); + } else { + res.status(500).json({ error: "Failed to revoke session" }); + } + } catch (err) { + authLogger.error("Failed to revoke session", err); + res.status(500).json({ error: "Failed to revoke session" }); + } +}); + +/** + * @openapi + * /users/sessions/revoke-all: + * post: + * summary: Revoke all sessions for a user + * description: Revokes all sessions with option to exclude current session. + * tags: + * - Users + * requestBody: + * required: false + * content: + * application/json: + * schema: + * type: object + * properties: + * targetUserId: + * type: string + * exceptCurrent: + * type: boolean + * responses: + * 200: + * description: Sessions revoked successfully. + * 403: + * description: Not authorized to revoke sessions for other users. + * 404: + * description: User not found. + * 500: + * description: Failed to revoke sessions. + */ +router.post("/sessions/revoke-all", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + const { targetUserId, exceptCurrent } = req.body; + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({ error: "User not found" }); + } + + const userRecord = user[0]; + + let revokeUserId = userId; + if (targetUserId && userRecord.is_admin) { + revokeUserId = targetUserId; + } else if (targetUserId && targetUserId !== userId) { + return res.status(403).json({ + error: "Not authorized to revoke sessions for other users", + }); + } + + let currentSessionId: string | undefined; + if (exceptCurrent) { + const token = + req.cookies?.jwt || req.headers?.authorization?.split(" ")[1]; + if (token) { + const payload = await authManager.verifyJWTToken(token); + currentSessionId = payload?.sessionId; + } + } + + const revokedCount = await authManager.revokeAllUserSessions( + revokeUserId, + currentSessionId, + ); + + authLogger.success("User sessions revoked", { + operation: "user_sessions_revoke_all", + revokeUserId, + revokedBy: userId, + exceptCurrent, + revokedCount, + }); + + res.json({ + message: `${revokedCount} session(s) revoked successfully`, + count: revokedCount, + }); + } catch (err) { + authLogger.error("Failed to revoke user sessions", err); + res.status(500).json({ error: "Failed to revoke sessions" }); + } +}); + +/** + * @openapi + * /users/link-oidc-to-password: + * post: + * summary: Link OIDC user to password account + * description: Merges an OIDC-only account into a password-based account (admin only). + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * oidcUserId: + * type: string + * targetUsername: + * type: string + * responses: + * 200: + * description: Accounts linked successfully. + * 400: + * description: Invalid request or incompatible accounts. + * 403: + * description: Admin access required. + * 404: + * description: User not found. + * 500: + * description: Failed to link accounts. + */ +router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => { + const adminUserId = (req as AuthenticatedRequest).userId; + const { oidcUserId, targetUsername } = req.body; + + if (!isNonEmptyString(oidcUserId) || !isNonEmptyString(targetUsername)) { + return res.status(400).json({ + error: "OIDC user ID and target username are required", + }); + } + + try { + const adminUser = await db + .select() + .from(users) + .where(eq(users.id, adminUserId)); + if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { + return res.status(403).json({ error: "Admin access required" }); + } + + const oidcUserRecords = await db + .select() + .from(users) + .where(eq(users.id, oidcUserId)); + if (!oidcUserRecords || oidcUserRecords.length === 0) { + return res.status(404).json({ error: "OIDC user not found" }); + } + + const oidcUser = oidcUserRecords[0]; + + if (!oidcUser.is_oidc) { + return res.status(400).json({ + error: "Source user is not an OIDC user", + }); + } + + const targetUserRecords = await db + .select() + .from(users) + .where(eq(users.username, targetUsername)); + if (!targetUserRecords || targetUserRecords.length === 0) { + return res.status(404).json({ error: "Target password user not found" }); + } + + const targetUser = targetUserRecords[0]; + + if (targetUser.is_oidc || !targetUser.password_hash) { + return res.status(400).json({ + error: "Target user must be a password-based account", + }); + } + + if (targetUser.client_id && targetUser.oidc_identifier) { + return res.status(400).json({ + error: "Target user already has OIDC authentication configured", + }); + } + + authLogger.info("Linking OIDC user to password account", { + operation: "link_oidc_to_password", + oidcUserId, + oidcUsername: oidcUser.username, + targetUserId: targetUser.id, + targetUsername: targetUser.username, + adminUserId, + }); + + await db + .update(users) + .set({ + is_oidc: true, + oidc_identifier: oidcUser.oidc_identifier, + client_id: oidcUser.client_id, + client_secret: oidcUser.client_secret, + issuer_url: oidcUser.issuer_url, + authorization_url: oidcUser.authorization_url, + token_url: oidcUser.token_url, + identifier_path: oidcUser.identifier_path, + name_path: oidcUser.name_path, + scopes: oidcUser.scopes || "openid email profile", + }) + .where(eq(users.id, targetUser.id)); + + try { + await authManager.convertToOIDCEncryption(targetUser.id); + } catch (encryptionError) { + authLogger.error( + "Failed to convert encryption to OIDC during linking", + encryptionError, + { + operation: "link_convert_encryption_failed", + userId: targetUser.id, + }, + ); + await db + .update(users) + .set({ + is_oidc: false, + oidc_identifier: null, + client_id: "", + client_secret: "", + issuer_url: "", + authorization_url: "", + token_url: "", + identifier_path: "", + name_path: "", + scopes: "openid email profile", + }) + .where(eq(users.id, targetUser.id)); + + return res.status(500).json({ + error: + "Failed to convert encryption for dual-auth. Please ensure the password account has encryption setup.", + details: + encryptionError instanceof Error + ? encryptionError.message + : "Unknown error", + }); + } + + await authManager.revokeAllUserSessions(oidcUserId); + authManager.logoutUser(oidcUserId); + + await deleteUserAndRelatedData(oidcUserId); + + try { + const { saveMemoryDatabaseToFile } = await import("../db/index.js"); + await saveMemoryDatabaseToFile(); + } catch (saveError) { + authLogger.error("Failed to persist account linking to disk", saveError, { + operation: "link_oidc_save_failed", + oidcUserId, + targetUserId: targetUser.id, + }); + } + + authLogger.success( + `OIDC user ${oidcUser.username} linked to password account ${targetUser.username}`, + { + operation: "link_oidc_to_password_success", + oidcUserId, + oidcUsername: oidcUser.username, + targetUserId: targetUser.id, + targetUsername: targetUser.username, + adminUserId, + }, + ); + + res.json({ + success: true, + message: `OIDC user ${oidcUser.username} has been linked to ${targetUser.username}. The password account can now use both password and OIDC login.`, + }); + } catch (err) { + authLogger.error("Failed to link OIDC user to password account", err, { + operation: "link_oidc_to_password_failed", + oidcUserId, + targetUsername, + adminUserId, + }); + res.status(500).json({ + error: "Failed to link accounts", + details: err instanceof Error ? err.message : "Unknown error", + }); + } +}); + +/** + * @openapi + * /users/unlink-oidc-from-password: + * post: + * summary: Unlink OIDC from password account + * description: Removes OIDC authentication from a dual-auth account (admin only). + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * userId: + * type: string + * responses: + * 200: + * description: OIDC unlinked successfully. + * 400: + * description: Invalid request or user doesn't have OIDC. + * 403: + * description: Admin privileges required. + * 404: + * description: User not found. + * 500: + * description: Failed to unlink OIDC. + */ +router.post("/unlink-oidc-from-password", authenticateJWT, async (req, res) => { + const adminUserId = (req as AuthenticatedRequest).userId; + const { userId } = req.body; + + if (!userId) { + return res.status(400).json({ + error: "User ID is required", + }); + } + + try { + const adminUser = await db + .select() + .from(users) + .where(eq(users.id, adminUserId)); + + if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { + authLogger.warn("Non-admin attempted to unlink OIDC from password", { + operation: "unlink_oidc_unauthorized", + adminUserId, + targetUserId: userId, + }); + return res.status(403).json({ + error: "Admin privileges required", + }); + } + + const targetUserRecords = await db + .select() + .from(users) + .where(eq(users.id, userId)); + + if (!targetUserRecords || targetUserRecords.length === 0) { + return res.status(404).json({ + error: "User not found", + }); + } + + const targetUser = targetUserRecords[0]; + + if (!targetUser.is_oidc) { + return res.status(400).json({ + error: "User does not have OIDC authentication enabled", + }); + } + + if (!targetUser.password_hash || targetUser.password_hash === "") { + return res.status(400).json({ + error: + "Cannot unlink OIDC from a user without password authentication. This would leave the user unable to login.", + }); + } + + authLogger.info("Unlinking OIDC from password account", { + operation: "unlink_oidc_from_password_start", + targetUserId: targetUser.id, + targetUsername: targetUser.username, + adminUserId, + }); + + await db + .update(users) + .set({ + is_oidc: false, + oidc_identifier: null, + client_id: "", + client_secret: "", + issuer_url: "", + authorization_url: "", + token_url: "", + identifier_path: "", + name_path: "", + scopes: "openid email profile", + }) + .where(eq(users.id, targetUser.id)); + + try { + const { saveMemoryDatabaseToFile } = await import("../db/index.js"); + await saveMemoryDatabaseToFile(); + } catch (saveError) { + authLogger.error( + "Failed to save database after unlinking OIDC", + saveError, + { + operation: "unlink_oidc_save_failed", + targetUserId: targetUser.id, + }, + ); + } + + authLogger.success("OIDC unlinked from password account successfully", { + operation: "unlink_oidc_from_password_success", + targetUserId: targetUser.id, + targetUsername: targetUser.username, + adminUserId, + }); + + res.json({ + success: true, + message: `OIDC authentication has been removed from ${targetUser.username}. User can now only login with password.`, + }); + } catch (err) { + authLogger.error("Failed to unlink OIDC from password account", err, { + operation: "unlink_oidc_from_password_failed", + targetUserId: userId, + adminUserId, + }); + res.status(500).json({ + error: "Failed to unlink OIDC", + details: err instanceof Error ? err.message : "Unknown error", + }); + } +}); + export default router; From f7e99b5af57abdcf190d34ef4dbb1727693b4b48 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Thu, 15 Jan 2026 04:49:38 +0800 Subject: [PATCH 2/3] feat: add toggle for password reset feature in admin settings (#508) --- src/backend/database/routes/users.ts | 94 +++++++++++++++++++ src/locales/en.json | 1 + src/ui/desktop/apps/admin/AdminSettings.tsx | 26 +++++ .../apps/admin/tabs/GeneralSettingsTab.tsx | 29 ++++++ src/ui/main-axios.ts | 22 +++++ 5 files changed, 172 insertions(+) diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 5e7e099e..d286c704 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -1746,6 +1746,84 @@ router.patch("/password-login-allowed", authenticateJWT, async (req, res) => { } }); +/** + * @openapi + * /users/password-reset-allowed: + * get: + * summary: Get password reset status + * description: Checks if password reset is currently allowed. + * tags: + * - Users + * responses: + * 200: + * description: Password reset status. + * 500: + * description: Failed to get password reset allowed status. + */ +router.get("/password-reset-allowed", async (req, res) => { + try { + const row = db.$client + .prepare("SELECT value FROM settings WHERE key = 'allow_password_reset'") + .get(); + res.json({ + allowed: row ? (row as { value: string }).value === "true" : true, + }); + } catch (err) { + authLogger.error("Failed to get password reset allowed", err); + res.status(500).json({ error: "Failed to get password reset allowed" }); + } +}); + +/** + * @openapi + * /users/password-reset-allowed: + * patch: + * summary: Set password reset status + * description: Enables or disables password reset. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * allowed: + * type: boolean + * responses: + * 200: + * description: Password reset status updated. + * 400: + * description: Invalid value for allowed. + * 403: + * description: Not authorized. + * 500: + * description: Failed to set password reset allowed status. + */ +router.patch("/password-reset-allowed", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0 || !user[0].is_admin) { + return res.status(403).json({ error: "Not authorized" }); + } + const { allowed } = req.body; + if (typeof allowed !== "boolean") { + return res.status(400).json({ error: "Invalid value for allowed" }); + } + db.$client + .prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('allow_password_reset', ?)", + ) + .run(allowed ? "true" : "false"); + res.json({ allowed }); + } catch (err) { + authLogger.error("Failed to set password reset allowed", err); + res.status(500).json({ error: "Failed to set password reset allowed" }); + } +}); + /** * @openapi * /users/delete-account: @@ -1861,6 +1939,22 @@ router.delete("/delete-account", authenticateJWT, async (req, res) => { * description: Failed to initiate password reset. */ router.post("/initiate-reset", async (req, res) => { + try { + const row = db.$client + .prepare("SELECT value FROM settings WHERE key = 'allow_password_reset'") + .get(); + if (row && (row as { value: string }).value !== "true") { + return res + .status(403) + .json({ error: "Password reset is currently disabled" }); + } + } catch (e) { + authLogger.warn("Failed to check password reset status", { + operation: "password_reset_check", + error: e, + }); + } + const { username } = req.body; if (!isNonEmptyString(username)) { diff --git a/src/locales/en.json b/src/locales/en.json index 812ea17e..dea8a24d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -539,6 +539,7 @@ "userRegistration": "User Registration", "allowNewAccountRegistration": "Allow new account registration", "allowPasswordLogin": "Allow username/password login", + "allowPasswordReset": "Allow password reset via reset code", "missingRequiredFields": "Missing required fields: {{fields}}", "oidcConfigurationUpdated": "OIDC configuration updated successfully!", "failedToFetchOidcConfig": "Failed to fetch OIDC configuration", diff --git a/src/ui/desktop/apps/admin/AdminSettings.tsx b/src/ui/desktop/apps/admin/AdminSettings.tsx index 8f226339..3883a82e 100644 --- a/src/ui/desktop/apps/admin/AdminSettings.tsx +++ b/src/ui/desktop/apps/admin/AdminSettings.tsx @@ -15,6 +15,7 @@ import { getAdminOIDCConfig, getRegistrationAllowed, getPasswordLoginAllowed, + getPasswordResetAllowed, getUserList, getUserInfo, isElectron, @@ -48,6 +49,7 @@ export function AdminSettings({ const [allowRegistration, setAllowRegistration] = React.useState(true); const [allowPasswordLogin, setAllowPasswordLogin] = React.useState(true); + const [allowPasswordReset, setAllowPasswordReset] = React.useState(true); const [oidcConfig, setOidcConfig] = React.useState({ client_id: "", @@ -193,6 +195,28 @@ export function AdminSettings({ }); }, []); + React.useEffect(() => { + if (isElectron()) { + const serverUrl = (window as { configuredServerUrl?: string }) + .configuredServerUrl; + if (!serverUrl) { + return; + } + } + + getPasswordResetAllowed() + .then((res) => { + if (typeof res === "boolean") { + setAllowPasswordReset(res); + } + }) + .catch((err) => { + if (err.code !== "NO_SERVER_CONFIGURED") { + console.warn("Failed to fetch password reset status", err); + } + }); + }, []); + const fetchUsers = async () => { if (isElectron()) { const serverUrl = (window as { configuredServerUrl?: string }) @@ -367,6 +391,8 @@ export function AdminSettings({ setAllowRegistration={setAllowRegistration} allowPasswordLogin={allowPasswordLogin} setAllowPasswordLogin={setAllowPasswordLogin} + allowPasswordReset={allowPasswordReset} + setAllowPasswordReset={setAllowPasswordReset} oidcConfig={oidcConfig} /> diff --git a/src/ui/desktop/apps/admin/tabs/GeneralSettingsTab.tsx b/src/ui/desktop/apps/admin/tabs/GeneralSettingsTab.tsx index 51e9fdc2..1b88c8a4 100644 --- a/src/ui/desktop/apps/admin/tabs/GeneralSettingsTab.tsx +++ b/src/ui/desktop/apps/admin/tabs/GeneralSettingsTab.tsx @@ -6,6 +6,7 @@ import { useConfirmation } from "@/hooks/use-confirmation.ts"; import { updateRegistrationAllowed, updatePasswordLoginAllowed, + updatePasswordResetAllowed, } from "@/ui/main-axios.ts"; interface GeneralSettingsTabProps { @@ -13,6 +14,8 @@ interface GeneralSettingsTabProps { setAllowRegistration: (value: boolean) => void; allowPasswordLogin: boolean; setAllowPasswordLogin: (value: boolean) => void; + allowPasswordReset: boolean; + setAllowPasswordReset: (value: boolean) => void; oidcConfig: { client_id: string; client_secret: string; @@ -27,6 +30,8 @@ export function GeneralSettingsTab({ setAllowRegistration, allowPasswordLogin, setAllowPasswordLogin, + allowPasswordReset, + setAllowPasswordReset, oidcConfig, }: GeneralSettingsTabProps): React.ReactElement { const { t } = useTranslation(); @@ -34,6 +39,7 @@ export function GeneralSettingsTab({ const [regLoading, setRegLoading] = React.useState(false); const [passwordLoginLoading, setPasswordLoginLoading] = React.useState(false); + const [passwordResetLoading, setPasswordResetLoading] = React.useState(false); const handleToggleRegistration = async (checked: boolean) => { setRegLoading(true); @@ -96,6 +102,16 @@ export function GeneralSettingsTab({ } }; + const handleTogglePasswordReset = async (checked: boolean) => { + setPasswordResetLoading(true); + try { + await updatePasswordResetAllowed(checked); + setAllowPasswordReset(checked); + } finally { + setPasswordResetLoading(false); + } + }; + return (

{t("admin.userRegistration")}

@@ -120,6 +136,19 @@ export function GeneralSettingsTab({ /> {t("admin.allowPasswordLogin")} +
); } diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 1a747c16..0e322433 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -2501,6 +2501,28 @@ export async function updatePasswordLoginAllowed( } } +export async function getPasswordResetAllowed(): Promise { + try { + const response = await authApi.get("/users/password-reset-allowed"); + return response.data.allowed; + } catch (error) { + handleApiError(error, "get password reset allowed"); + } +} + +export async function updatePasswordResetAllowed( + allowed: boolean, +): Promise<{ allowed: boolean }> { + try { + const response = await authApi.patch("/users/password-reset-allowed", { + allowed, + }); + return response.data; + } catch (error) { + handleApiError(error, "update password reset allowed"); + } +} + export async function updateOIDCConfig( config: Record, ): Promise> { From 042bf255ef54e14942f05caea8a309924b1053ad Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Thu, 15 Jan 2026 04:54:20 +0800 Subject: [PATCH 3/3] feat: add sudo support for file manager operations (#509) --- src/backend/ssh/file-manager.ts | 246 ++++++++++++------ src/locales/en.json | 5 + .../features/file-manager/FileManager.tsx | 70 ++++- .../file-manager/SudoPasswordDialog.tsx | 98 +++++++ src/ui/main-axios.ts | 14 + 5 files changed, 356 insertions(+), 77 deletions(-) create mode 100644 src/ui/desktop/apps/features/file-manager/SudoPasswordDialog.tsx diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 2e5ade28..0860a472 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -315,6 +315,7 @@ interface SSHSession { lastActive: number; timeout?: NodeJS.Timeout; activeOperations: number; + sudoPassword?: string; } interface PendingTOTPSession { @@ -337,6 +338,45 @@ interface PendingTOTPSession { const sshSessions: Record = {}; const pendingTOTPSessions: Record = {}; +function execWithSudo( + client: SSHClient, + command: string, + sudoPassword: string, +): Promise<{ stdout: string; stderr: string; code: number }> { + return new Promise((resolve) => { + const escapedPassword = sudoPassword.replace(/'/g, "'\"'\"'"); + const sudoCommand = `echo '${escapedPassword}' | sudo -S ${command} 2>&1`; + + client.exec(sudoCommand, (err, stream) => { + if (err) { + resolve({ stdout: "", stderr: err.message, code: 1 }); + return; + } + + let stdout = ""; + let stderr = ""; + + stream.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + + stream.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + stream.on("close", (code: number) => { + // Filter out sudo password prompt from output + stdout = stdout.replace(/\[sudo\] password for .+?:\s*/g, ""); + resolve({ stdout, stderr, code: code || 0 }); + }); + + stream.on("error", (streamErr: Error) => { + resolve({ stdout, stderr: streamErr.message, code: 1 }); + }); + }); + }); +} + function cleanupSession(sessionId: string) { const session = sshSessions[sessionId]; if (session) { @@ -1205,6 +1245,42 @@ app.post("/ssh/file_manager/ssh/disconnect", (req, res) => { res.json({ status: "success", message: "SSH connection disconnected" }); }); +/** + * @openapi + * /ssh/file_manager/sudo-password: + * post: + * summary: Set sudo password for session + * description: Stores sudo password temporarily in session for elevated operations. + * tags: + * - File Manager + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * password: + * type: string + * responses: + * 200: + * description: Sudo password set successfully. + * 400: + * description: Invalid session. + */ +app.post("/ssh/file_manager/sudo-password", (req, res) => { + const { sessionId, password } = req.body; + const session = sshSessions[sessionId]; + if (!session || !session.isConnected) { + return res.status(400).json({ error: "Invalid or disconnected session" }); + } + session.sudoPassword = password; + session.lastActive = Date.now(); + res.json({ status: "success", message: "Sudo password set" }); +}); + /** * @openapi * /ssh/file_manager/ssh/status: @@ -2657,86 +2733,106 @@ app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => { const escapedPath = itemPath.replace(/'/g, "'\"'\"'"); const deleteCommand = isDirectory - ? `rm -rf '${escapedPath}' && echo "SUCCESS" && exit 0` - : `rm -f '${escapedPath}' && echo "SUCCESS" && exit 0`; + ? `rm -rf '${escapedPath}'` + : `rm -f '${escapedPath}'`; - sshConn.client.exec(deleteCommand, (err, stream) => { - if (err) { - fileLogger.error("SSH deleteItem error:", err); - if (!res.headersSent) { - return res.status(500).json({ error: err.message }); - } - return; - } - - let outputData = ""; - let errorData = ""; - - stream.on("data", (chunk: Buffer) => { - outputData += chunk.toString(); - }); - - stream.stderr.on("data", (chunk: Buffer) => { - errorData += chunk.toString(); - - if (chunk.toString().includes("Permission denied")) { - fileLogger.error(`Permission denied deleting: ${itemPath}`); - if (!res.headersSent) { - return res.status(403).json({ - error: `Permission denied: Cannot delete ${itemPath}. Check file permissions.`, - }); - } - return; - } - }); - - stream.on("close", (code) => { - if (outputData.includes("SUCCESS")) { - if (!res.headersSent) { - res.json({ - message: "Item deleted successfully", - path: itemPath, - toast: { - type: "success", - message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`, - }, - }); - } - return; - } - - if (code !== 0) { - fileLogger.error( - `SSH deleteItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, - ); - if (!res.headersSent) { - return res.status(500).json({ - error: `Command failed: ${errorData}`, - toast: { type: "error", message: `Delete failed: ${errorData}` }, - }); - } - return; - } - - if (!res.headersSent) { - res.json({ - message: "Item deleted successfully", - path: itemPath, - toast: { - type: "success", - message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`, + const executeDelete = (useSudo: boolean): Promise => { + return new Promise((resolve) => { + if (useSudo && sshConn.sudoPassword) { + execWithSudo(sshConn.client, deleteCommand, sshConn.sudoPassword).then( + (result) => { + if ( + result.code === 0 || + (!result.stderr.includes("Permission denied") && + !result.stdout.includes("Permission denied")) + ) { + res.json({ + message: "Item deleted successfully", + path: itemPath, + toast: { + type: "success", + message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`, + }, + }); + } else { + res.status(500).json({ + error: `Delete failed: ${result.stderr || result.stdout}`, + }); + } + resolve(); }, - }); + ); + return; } - }); - stream.on("error", (streamErr) => { - fileLogger.error("SSH deleteItem stream error:", streamErr); - if (!res.headersSent) { - res.status(500).json({ error: `Stream error: ${streamErr.message}` }); - } + sshConn.client.exec( + `${deleteCommand} && echo "SUCCESS"`, + (err, stream) => { + if (err) { + fileLogger.error("SSH deleteItem error:", err); + res.status(500).json({ error: err.message }); + resolve(); + return; + } + + let outputData = ""; + let errorData = ""; + let permissionDenied = false; + + stream.on("data", (chunk: Buffer) => { + outputData += chunk.toString(); + }); + + stream.stderr.on("data", (chunk: Buffer) => { + errorData += chunk.toString(); + if (chunk.toString().includes("Permission denied")) { + permissionDenied = true; + } + }); + + stream.on("close", (code) => { + if (permissionDenied) { + if (sshConn.sudoPassword) { + executeDelete(true).then(resolve); + return; + } + fileLogger.error(`Permission denied deleting: ${itemPath}`); + res.status(403).json({ + error: `Permission denied: Cannot delete ${itemPath}.`, + needsSudo: true, + }); + resolve(); + return; + } + + if (outputData.includes("SUCCESS") || code === 0) { + res.json({ + message: "Item deleted successfully", + path: itemPath, + toast: { + type: "success", + message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`, + }, + }); + } else { + res.status(500).json({ + error: `Command failed: ${errorData}`, + }); + } + resolve(); + }); + + stream.on("error", (streamErr) => { + fileLogger.error("SSH deleteItem stream error:", streamErr); + res.status(500).json({ error: `Stream error: ${streamErr.message}` }); + resolve(); + }); + }, + ); }); - }); + }; + + await executeDelete(false); }); /** diff --git a/src/locales/en.json b/src/locales/en.json index dea8a24d..b334426c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1376,6 +1376,11 @@ "itemDeletedSuccessfully": "{{type}} deleted successfully", "itemsDeletedSuccessfully": "{{count}} items deleted successfully", "failedToDeleteItems": "Failed to delete items", + "sudoPasswordRequired": "Administrator Password Required", + "enterSudoPassword": "Enter sudo password to continue this operation", + "sudoPassword": "Sudo password", + "sudoOperationFailed": "Sudo operation failed", + "deleteOperation": "Delete files/folders", "dragFilesToUpload": "Drop files here to upload", "emptyFolder": "This folder is empty", "itemCount": "{{count}} items", diff --git a/src/ui/desktop/apps/features/file-manager/FileManager.tsx b/src/ui/desktop/apps/features/file-manager/FileManager.tsx index 1a25c73e..145c3bf0 100644 --- a/src/ui/desktop/apps/features/file-manager/FileManager.tsx +++ b/src/ui/desktop/apps/features/file-manager/FileManager.tsx @@ -21,6 +21,7 @@ import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx"; import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx"; import { PermissionsDialog } from "./components/PermissionsDialog.tsx"; import { CompressDialog } from "./components/CompressDialog.tsx"; +import { SudoPasswordDialog } from "./SudoPasswordDialog.tsx"; import { Upload, FolderPlus, @@ -57,6 +58,7 @@ import { changeSSHPermissions, extractSSHArchive, compressSSHFiles, + setSudoPassword, } from "@/ui/main-axios.ts"; import type { SidebarItem } from "./FileManagerSidebar.tsx"; @@ -163,6 +165,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { [], ); + const [sudoDialogOpen, setSudoDialogOpen] = useState(false); + const [pendingSudoOperation, setPendingSudoOperation] = useState<{ + type: "delete"; + files: FileItem[]; + } | null>(null); + const { selectedFiles, clearSelection, setSelection } = useFileSelection(); const { dragHandlers } = useDragAndDrop({ @@ -720,9 +728,18 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { handleRefreshDirectory(); clearSelection(); } catch (error: unknown) { + const axiosError = error as { + response?: { data?: { needsSudo?: boolean; error?: string } }; + message?: string; + }; + if (axiosError.response?.data?.needsSudo) { + setPendingSudoOperation({ type: "delete", files }); + setSudoDialogOpen(true); + return; + } if ( - error.message?.includes("connection") || - error.message?.includes("established") + axiosError.message?.includes("connection") || + axiosError.message?.includes("established") ) { toast.error( `SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`, @@ -737,6 +754,41 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { ); } + async function handleSudoPasswordSubmit(password: string) { + if (!sshSessionId || !pendingSudoOperation) return; + + try { + await setSudoPassword(sshSessionId, password); + setSudoDialogOpen(false); + + if (pendingSudoOperation.type === "delete") { + for (const file of pendingSudoOperation.files) { + await deleteSSHItem( + sshSessionId, + file.path, + file.type === "directory", + currentHost?.id, + currentHost?.userId?.toString(), + ); + } + toast.success( + t("fileManager.itemsDeletedSuccessfully", { + count: pendingSudoOperation.files.length, + }), + ); + handleRefreshDirectory(); + clearSelection(); + } + + setPendingSudoOperation(null); + } catch (error: unknown) { + const axiosError = error as { message?: string }; + toast.error( + axiosError.message || t("fileManager.sudoOperationFailed"), + ); + } + } + function handleCreateNewFolder() { const defaultName = generateUniqueName( t("fileManager.newFolderDefault"), @@ -2173,6 +2225,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { }} onSave={handleSavePermissions} /> + + { + setSudoDialogOpen(open); + if (!open) setPendingSudoOperation(null); + }} + onSubmit={handleSudoPasswordSubmit} + operation={ + pendingSudoOperation?.type === "delete" + ? t("fileManager.deleteOperation") + : undefined + } + /> ); } diff --git a/src/ui/desktop/apps/features/file-manager/SudoPasswordDialog.tsx b/src/ui/desktop/apps/features/file-manager/SudoPasswordDialog.tsx new file mode 100644 index 00000000..469d367e --- /dev/null +++ b/src/ui/desktop/apps/features/file-manager/SudoPasswordDialog.tsx @@ -0,0 +1,98 @@ +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog.tsx"; +import { Button } from "@/components/ui/button.tsx"; +import { PasswordInput } from "@/components/ui/password-input.tsx"; +import { useTranslation } from "react-i18next"; +import { ShieldAlert } from "lucide-react"; + +interface SudoPasswordDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (password: string) => void; + operation?: string; +} + +export function SudoPasswordDialog({ + open, + onOpenChange, + onSubmit, + operation, +}: SudoPasswordDialogProps) { + const { t } = useTranslation(); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open) { + setPassword(""); + setLoading(false); + } + }, [open]); + + const handleSubmit = async (e?: React.FormEvent) => { + if (e) { + e.preventDefault(); + } + + if (!password.trim()) { + return; + } + + setLoading(true); + onSubmit(password); + }; + + return ( + + +
+ + + + {t("fileManager.sudoPasswordRequired")} + + + {t("fileManager.enterSudoPassword")} + {operation && ( + + {operation} + + )} + + + +
+ setPassword(e.target.value)} + placeholder={t("fileManager.sudoPassword")} + autoFocus + disabled={loading} + /> +
+ + + + + +
+
+
+ ); +} diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 0e322433..b7c0b7ba 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -1641,6 +1641,20 @@ export async function deleteSSHItem( } } +export async function setSudoPassword( + sessionId: string, + password: string, +): Promise { + try { + await fileManagerApi.post("/sudo-password", { + sessionId, + password, + }); + } catch (error) { + handleApiError(error, "set sudo password"); + } +} + export async function copySSHItem( sessionId: string, sourcePath: string,