diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index a00204bb..939b283a 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -290,12 +290,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { }; if ( - resolvedCredentials.authType === "password" && - resolvedCredentials.password && - resolvedCredentials.password.trim() - ) { - config.password = resolvedCredentials.password; - } else if ( resolvedCredentials.authType === "key" && resolvedCredentials.sshKey && resolvedCredentials.sshKey.trim() @@ -326,6 +320,22 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { }); return res.status(400).json({ error: "Invalid SSH key format" }); } + } else if (resolvedCredentials.authType === "password") { + if (!resolvedCredentials.password || !resolvedCredentials.password.trim()) { + fileLogger.warn( + "Password authentication requested but no password provided", + { + operation: "file_connect", + sessionId, + hostId, + }, + ); + return res + .status(400) + .json({ error: "Password required for password authentication" }); + } + // Set password to offer both password and keyboard-interactive methods + config.password = resolvedCredentials.password; } else { fileLogger.warn( "No valid authentication method provided for file manager", @@ -403,6 +413,22 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { if (responseSent) return; responseSent = true; + if (pendingTOTPSessions[sessionId]) { + fileLogger.warn( + "TOTP session already exists, cleaning up old client", + { + operation: "file_keyboard_interactive", + hostId, + sessionId, + }, + ); + try { + pendingTOTPSessions[sessionId].client.end(); + } catch (e) { + // Ignore cleanup errors + } + } + pendingTOTPSessions[sessionId] = { client, finish, @@ -411,6 +437,13 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { sessionId, }; + fileLogger.info("Created TOTP session", { + operation: "file_keyboard_interactive_totp", + hostId, + sessionId, + prompt: totpPrompt.prompt, + }); + res.json({ requires_totp: true, sessionId, @@ -456,25 +489,38 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { operation: "file_totp_verify", sessionId, userId, + availableSessions: Object.keys(pendingTOTPSessions), }); return res .status(404) .json({ error: "TOTP session expired. Please reconnect." }); } - delete pendingTOTPSessions[sessionId]; - - if (Date.now() - session.createdAt > 120000) { + if (Date.now() - session.createdAt > 180000) { + delete pendingTOTPSessions[sessionId]; try { session.client.end(); } catch { // Ignore errors when closing timed out session } + fileLogger.warn("TOTP session timeout before code submission", { + operation: "file_totp_verify", + sessionId, + userId, + age: Date.now() - session.createdAt, + }); return res .status(408) .json({ error: "TOTP session timeout. Please reconnect." }); } + fileLogger.info("Submitting TOTP code to SSH server", { + operation: "file_totp_verify", + sessionId, + userId, + codeLength: totpCode.length, + }); + session.finish([totpCode]); let responseSent = false; @@ -483,6 +529,8 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { if (responseSent) return; responseSent = true; + delete pendingTOTPSessions[sessionId]; + sshSessions[sessionId] = { client: session.client, isConnected: true, @@ -506,6 +554,8 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { if (responseSent) return; responseSent = true; + delete pendingTOTPSessions[sessionId]; + fileLogger.error("TOTP verification failed", { operation: "file_totp_verify", sessionId, @@ -519,6 +569,7 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { setTimeout(() => { if (!responseSent) { responseSent = true; + delete pendingTOTPSessions[sessionId]; res.status(408).json({ error: "TOTP verification timeout" }); } }, 60000); diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index 38ed66c9..9fcd648d 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -577,7 +577,7 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig { if (!host.password) { throw new Error(`No password available for host ${host.ip}`); } - (base as Record).password = host.password; + // Don't set password in config - let keyboard-interactive handle it } else if (host.authType === "key") { if (!host.key) { throw new Error(`No SSH key available for host ${host.ip}`); diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index eee946a8..c276a2ce 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -672,7 +672,25 @@ wss.on("connection", async (ws: WebSocket, req) => { totpCode, ]); - finish([totpCode]); + // Respond to ALL prompts, not just TOTP + const responses = prompts.map((p, index) => { + if (index === totpPromptIndex) { + return totpCode; + } + if (/password/i.test(p.prompt) && resolvedCredentials.password) { + return resolvedCredentials.password; + } + return ""; + }); + + sshLogger.info("Full keyboard-interactive response", { + operation: "totp_full_response", + hostId: id, + totalPrompts: prompts.length, + responsesProvided: responses.filter((r) => r !== "").length, + }); + + finish(responses); }; ws.send( JSON.stringify({ @@ -768,15 +786,8 @@ wss.on("connection", async (ws: WebSocket, req) => { compress: ["none", "zlib@openssh.com", "zlib"], }, }; - if ( - resolvedCredentials.authType === "password" && - resolvedCredentials.password - ) { - connectConfig.password = resolvedCredentials.password; - } else if ( - resolvedCredentials.authType === "key" && - resolvedCredentials.key - ) { + + if (resolvedCredentials.authType === "key" && resolvedCredentials.key) { try { if ( !resolvedCredentials.key.includes("-----BEGIN") || @@ -814,6 +825,22 @@ wss.on("connection", async (ws: WebSocket, req) => { }), ); return; + } else if (resolvedCredentials.authType === "password") { + if (!resolvedCredentials.password) { + sshLogger.error( + "Password authentication requested but no password provided", + ); + ws.send( + JSON.stringify({ + type: "error", + message: + "Password authentication requested but no password provided", + }), + ); + return; + } + // Set password to offer both password and keyboard-interactive methods + connectConfig.password = resolvedCredentials.password; } else { sshLogger.error("No valid authentication method provided"); ws.send( diff --git a/src/ui/Desktop/Apps/Homepage/Apps/Alerts/AlertCard.tsx b/src/ui/Desktop/Apps/Dashboard/Apps/Alerts/AlertCard.tsx similarity index 100% rename from src/ui/Desktop/Apps/Homepage/Apps/Alerts/AlertCard.tsx rename to src/ui/Desktop/Apps/Dashboard/Apps/Alerts/AlertCard.tsx diff --git a/src/ui/Desktop/Apps/Homepage/Apps/Alerts/AlertManager.tsx b/src/ui/Desktop/Apps/Dashboard/Apps/Alerts/AlertManager.tsx similarity index 100% rename from src/ui/Desktop/Apps/Homepage/Apps/Alerts/AlertManager.tsx rename to src/ui/Desktop/Apps/Dashboard/Apps/Alerts/AlertManager.tsx diff --git a/src/ui/Desktop/Apps/Homepage/Apps/UpdateLog.tsx b/src/ui/Desktop/Apps/Dashboard/Apps/UpdateLog.tsx similarity index 98% rename from src/ui/Desktop/Apps/Homepage/Apps/UpdateLog.tsx rename to src/ui/Desktop/Apps/Dashboard/Apps/UpdateLog.tsx index 93955d5b..f587480e 100644 --- a/src/ui/Desktop/Apps/Homepage/Apps/UpdateLog.tsx +++ b/src/ui/Desktop/Apps/Dashboard/Apps/UpdateLog.tsx @@ -13,7 +13,7 @@ import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts"; import { useTranslation } from "react-i18next"; import { BookOpen, X } from "lucide-react"; -interface HomepageUpdateLogProps extends React.ComponentProps<"div"> { +interface UpdateLogProps extends React.ComponentProps<"div"> { loggedIn: boolean; } @@ -59,7 +59,7 @@ interface VersionResponse { cache_age?: number; } -export function HomepageUpdateLog({ loggedIn }: HomepageUpdateLogProps) { +export function UpdateLog({ loggedIn }: UpdateLogProps) { const { t } = useTranslation(); const [releases, setReleases] = useState(null); const [versionInfo, setVersionInfo] = useState(null); diff --git a/src/ui/Desktop/Apps/Dashboard/Dashboard.tsx b/src/ui/Desktop/Apps/Dashboard/Dashboard.tsx new file mode 100644 index 00000000..2e689940 --- /dev/null +++ b/src/ui/Desktop/Apps/Dashboard/Dashboard.tsx @@ -0,0 +1,204 @@ +import React, { useEffect, useState } from "react"; +import { Auth } from "@/ui/Desktop/Authentication/Auth.tsx"; +import { UpdateLog } from "@/ui/Desktop/Apps/Dashboard/Apps/UpdateLog.tsx"; +import { AlertManager } from "@/ui/Desktop/Apps/Dashboard/Apps/Alerts/AlertManager.tsx"; +import { Button } from "@/components/ui/button.tsx"; +import { getUserInfo, getDatabaseHealth, getCookie } from "@/ui/main-axios.ts"; +import { useSidebar } from "@/components/ui/sidebar.tsx"; +import { Separator } from "@/components/ui/separator.tsx"; +import { ChartLine, History } from "lucide-react"; +import { Status } from "@/components/ui/shadcn-io/status"; + +interface DashboardProps { + onSelectView: (view: string) => void; + isAuthenticated: boolean; + authLoading: boolean; + onAuthSuccess: (authData: { + isAdmin: boolean; + username: string | null; + userId: string | null; + }) => void; + isTopbarOpen: boolean; +} + +export function Dashboard({ + isAuthenticated, + authLoading, + onAuthSuccess, + isTopbarOpen, +}: DashboardProps): React.ReactElement { + const [loggedIn, setLoggedIn] = useState(isAuthenticated); + const [, setIsAdmin] = useState(false); + const [, setUsername] = useState(null); + const [userId, setUserId] = useState(null); + const [dbError, setDbError] = useState(null); + + let sidebarState: "expanded" | "collapsed" = "expanded"; + try { + const sidebar = useSidebar(); + sidebarState = sidebar.state; + } catch {} + + const topMarginPx = isTopbarOpen ? 74 : 26; + const leftMarginPx = sidebarState === "collapsed" ? 26 : 8; + const bottomMarginPx = 8; + + useEffect(() => { + setLoggedIn(isAuthenticated); + }, [isAuthenticated]); + + useEffect(() => { + if (isAuthenticated) { + if (getCookie("jwt")) { + getUserInfo() + .then((meRes) => { + setIsAdmin(!!meRes.is_admin); + setUsername(meRes.username || null); + setUserId(meRes.userId || null); + setDbError(null); + }) + .catch((err) => { + setIsAdmin(false); + setUsername(null); + setUserId(null); + + const errorCode = err?.response?.data?.code; + if (errorCode === "SESSION_EXPIRED") { + console.warn("Session expired - please log in again"); + setDbError("Session expired - please log in again"); + } else { + setDbError(null); + } + }); + + getDatabaseHealth() + .then(() => { + setDbError(null); + }) + .catch((err) => { + if (err?.response?.data?.error?.includes("Database")) { + setDbError( + "Could not connect to the database. Please try again later.", + ); + } + }); + } + } + }, [isAuthenticated]); + + return ( + <> + {!loggedIn ? ( +
+ +
+ ) : ( +
+
+
+
Dashboard
+
+ + + + +
+
+ + + +
+
+
+
+

+ + Server Status +

+
+ +

Version

+
+
+
+
+ test +
+
+
+
+ test +
+
+ test +
+
+
+
+
+ )} + + + + ); +} diff --git a/src/ui/Desktop/Apps/Homepage/Homepage.tsx b/src/ui/Desktop/Apps/Homepage/Homepage.tsx deleted file mode 100644 index a45be3f6..00000000 --- a/src/ui/Desktop/Apps/Homepage/Homepage.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { Auth } from "@/ui/Desktop/Authentication/Auth.tsx"; -import { HomepageUpdateLog } from "@/ui/Desktop/Apps/Homepage/Apps/UpdateLog.tsx"; -import { AlertManager } from "@/ui/Desktop/Apps/Homepage/Apps/Alerts/AlertManager.tsx"; -import { Button } from "@/components/ui/button.tsx"; -import { getUserInfo, getDatabaseHealth, getCookie } from "@/ui/main-axios.ts"; -import { useSidebar } from "@/components/ui/sidebar.tsx"; - -interface HomepageProps { - onSelectView: (view: string) => void; - isAuthenticated: boolean; - authLoading: boolean; - onAuthSuccess: (authData: { - isAdmin: boolean; - username: string | null; - userId: string | null; - }) => void; - isTopbarOpen: boolean; -} - -export function Homepage({ - isAuthenticated, - authLoading, - onAuthSuccess, - isTopbarOpen, -}: HomepageProps): React.ReactElement { - const [loggedIn, setLoggedIn] = useState(isAuthenticated); - const [, setIsAdmin] = useState(false); - const [, setUsername] = useState(null); - const [userId, setUserId] = useState(null); - const [dbError, setDbError] = useState(null); - - let sidebarState: "expanded" | "collapsed" = "expanded"; - try { - const sidebar = useSidebar(); - sidebarState = sidebar.state; - } catch {} - - const topMarginPx = isTopbarOpen ? 74 : 26; - const leftMarginPx = sidebarState === "collapsed" ? 26 : 8; - const bottomMarginPx = 8; - - useEffect(() => { - setLoggedIn(isAuthenticated); - }, [isAuthenticated]); - - useEffect(() => { - if (isAuthenticated) { - if (getCookie("jwt")) { - getUserInfo() - .then((meRes) => { - setIsAdmin(!!meRes.is_admin); - setUsername(meRes.username || null); - setUserId(meRes.userId || null); - setDbError(null); - }) - .catch((err) => { - setIsAdmin(false); - setUsername(null); - setUserId(null); - - const errorCode = err?.response?.data?.code; - if (errorCode === "SESSION_EXPIRED") { - console.warn("Session expired - please log in again"); - setDbError("Session expired - please log in again"); - } else { - setDbError(null); - } - }); - - getDatabaseHealth() - .then(() => { - setDbError(null); - }) - .catch((err) => { - if (err?.response?.data?.error?.includes("Database")) { - setDbError( - "Could not connect to the database. Please try again later.", - ); - } - }); - } - } - }, [isAuthenticated]); - - return ( - <> - {!loggedIn ? ( -
- -
- ) : ( -
-
- - -
- -
- -
- -
- -
-
-
- )} - - - - ); -} diff --git a/src/ui/Desktop/Authentication/Auth.tsx b/src/ui/Desktop/Authentication/Auth.tsx index 05ecfb14..bb5e449a 100644 --- a/src/ui/Desktop/Authentication/Auth.tsx +++ b/src/ui/Desktop/Authentication/Auth.tsx @@ -25,7 +25,7 @@ import { } from "../../main-axios.ts"; import { ElectronServerConfig as ServerConfigComponent } from "@/ui/Desktop/Authentication/ElectronServerConfig.tsx"; -interface HomepageAuthProps extends React.ComponentProps<"div"> { +interface AuthProps extends React.ComponentProps<"div"> { setLoggedIn: (loggedIn: boolean) => void; setIsAdmin: (isAdmin: boolean) => void; setUsername: (username: string | null) => void; @@ -51,7 +51,7 @@ export function Auth({ setDbError, onAuthSuccess, ...props -}: HomepageAuthProps) { +}: AuthProps) { const { t } = useTranslation(); const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">( "login", diff --git a/src/ui/Desktop/DesktopApp.tsx b/src/ui/Desktop/DesktopApp.tsx index 46751393..3c63146c 100644 --- a/src/ui/Desktop/DesktopApp.tsx +++ b/src/ui/Desktop/DesktopApp.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from "react"; import { LeftSidebar } from "@/ui/Desktop/Navigation/LeftSidebar.tsx"; -import { Homepage } from "@/ui/Desktop/Apps/Homepage/Homepage.tsx"; +import { Dashboard } from "@/ui/Desktop/Apps/Dashboard/Dashboard.tsx"; import { AppView } from "@/ui/Desktop/Navigation/AppView.tsx"; import { HostManager } from "@/ui/Desktop/Apps/Host Manager/HostManager.tsx"; import { @@ -123,7 +123,7 @@ function AppContent() { {!isAuthenticated && !authLoading && !showVersionCheck && (
- - { +interface AuthProps extends React.ComponentProps<"div"> { setLoggedIn: (loggedIn: boolean) => void; setIsAdmin: (isAdmin: boolean) => void; setUsername: (username: string | null) => void; @@ -40,7 +40,7 @@ interface HomepageAuthProps extends React.ComponentProps<"div"> { }) => void; } -export function HomepageAuth({ +export function Auth({ className, setLoggedIn, setIsAdmin, @@ -52,7 +52,7 @@ export function HomepageAuth({ setDbError, onAuthSuccess, ...props -}: HomepageAuthProps) { +}: AuthProps) { const { t } = useTranslation(); const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">( "login", diff --git a/src/ui/Mobile/MobileApp.tsx b/src/ui/Mobile/MobileApp.tsx index f51a2eb1..2d687e2d 100644 --- a/src/ui/Mobile/MobileApp.tsx +++ b/src/ui/Mobile/MobileApp.tsx @@ -8,7 +8,7 @@ import { useTabs, } from "@/ui/Mobile/Navigation/Tabs/TabContext.tsx"; import { getUserInfo } from "@/ui/main-axios.ts"; -import { HomepageAuth } from "@/ui/Mobile/Homepage/HomepageAuth.tsx"; +import { Auth } from "@/ui/Mobile/Authentication/Auth.tsx"; import { useTranslation } from "react-i18next"; import { Toaster } from "@/components/ui/sonner.tsx"; @@ -124,7 +124,7 @@ const AppContent: FC = () => { if (!isAuthenticated) { return (
-