diff --git a/src/backend/homepage.ts b/src/backend/dashboard.ts similarity index 91% rename from src/backend/homepage.ts rename to src/backend/dashboard.ts index 82e1c95d..2a496ea8 100644 --- a/src/backend/homepage.ts +++ b/src/backend/dashboard.ts @@ -4,7 +4,7 @@ import cookieParser from "cookie-parser"; import { getDb } from "./database/db/index.js"; import { recentActivity, sshData } from "./database/db/schema.js"; import { eq, and, desc } from "drizzle-orm"; -import { homepageLogger } from "./utils/logger.js"; +import { dashboardLogger } from "./utils/logger.js"; import { SimpleDBOps } from "./utils/simple-db-ops.js"; import { AuthManager } from "./utils/auth-manager.js"; import type { AuthenticatedRequest } from "../types/index.js"; @@ -71,7 +71,7 @@ app.get("/uptime", async (req, res) => { formatted: `${days}d ${hours}h ${minutes}m`, }); } catch (err) { - homepageLogger.error("Failed to get uptime", err); + dashboardLogger.error("Failed to get uptime", err); res.status(500).json({ error: "Failed to get uptime" }); } }); @@ -103,7 +103,7 @@ app.get("/activity/recent", async (req, res) => { res.json(activities); } catch (err) { - homepageLogger.error("Failed to get recent activity", err); + dashboardLogger.error("Failed to get recent activity", err); res.status(500).json({ error: "Failed to get recent activity" }); } }); @@ -211,7 +211,7 @@ app.post("/activity/log", async (req, res) => { res.json({ message: "Activity logged", id: result.id }); } catch (err) { - homepageLogger.error("Failed to log activity", err); + dashboardLogger.error("Failed to log activity", err); res.status(500).json({ error: "Failed to log activity" }); } }); @@ -229,22 +229,20 @@ app.delete("/activity/reset", async (req, res) => { } // Delete all activities for the user - const activities = await SimpleDBOps.select( - getDb() - .select() - .from(recentActivity) - .where(eq(recentActivity.userId, userId)), + await SimpleDBOps.delete( + recentActivity, "recent_activity", - userId, + eq(recentActivity.userId, userId), ); - for (const activity of activities) { - await SimpleDBOps.delete(recentActivity, "recent_activity", userId); - } + dashboardLogger.success("Recent activity cleared", { + operation: "reset_recent_activity", + userId, + }); res.json({ message: "Recent activity cleared" }); } catch (err) { - homepageLogger.error("Failed to reset activity", err); + dashboardLogger.error("Failed to reset activity", err); res.status(500).json({ error: "Failed to reset activity" }); } }); @@ -254,7 +252,7 @@ app.listen(PORT, async () => { try { await authManager.initialize(); } catch (err) { - homepageLogger.error("Failed to initialize AuthManager", err, { + dashboardLogger.error("Failed to initialize AuthManager", err, { operation: "auth_init_error", }); } diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 4ed59561..d6c95cd5 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -678,7 +678,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => { decrypted.authType, decrypted.password || null, decrypted.key || null, - decrypted.key_password || null, + decrypted.keyPassword || decrypted.key_password || null, decrypted.keyType || null, decrypted.autostartPassword || null, decrypted.autostartKey || null, @@ -721,9 +721,9 @@ app.post("/database/export", authenticateJWT, async (req, res) => { decrypted.username, decrypted.password || null, decrypted.key || null, - decrypted.private_key || null, - decrypted.public_key || null, - decrypted.key_password || null, + decrypted.privateKey || decrypted.private_key || null, + decrypted.publicKey || decrypted.public_key || null, + decrypted.keyPassword || decrypted.key_password || null, decrypted.keyType || null, decrypted.detectedKeyType || null, decrypted.usageCount || 0, diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index c5e44bc3..faf3c61b 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -287,6 +287,24 @@ async function initializeCompleteDatabase(): Promise { error: e, }); } + + try { + const row = sqlite + .prepare("SELECT value FROM settings WHERE key = 'allow_password_login'") + .get(); + if (!row) { + sqlite + .prepare( + "INSERT INTO settings (key, value) VALUES ('allow_password_login', 'true')", + ) + .run(); + } + } catch (e) { + databaseLogger.warn("Could not initialize allow_password_login setting", { + operation: "db_init", + error: e, + }); + } } const addColumnIfNotExists = ( diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index ee739279..1ea74cc9 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -61,27 +61,27 @@ async function verifyOIDCToken( authLogger.error(`OIDC discovery failed: ${discoveryError}`); } - let jwks: Record | null = null; + let jwks: Record | null = null; - for (const url of jwksUrls) { - try { - const response = await fetch(url); - if (response.ok) { - const jwksData = (await response.json()) as Record;; - if (jwksData && jwksData.keys && Array.isArray(jwksData.keys)) { - jwks = jwksData; - break; - } else { - authLogger.error( - `Invalid JWKS structure from ${url}: ${JSON.stringify(jwksData)}`, - ); - } + for (const url of jwksUrls) { + try { + const response = await fetch(url); + if (response.ok) { + const jwksData = (await response.json()) as Record; + if (jwksData && jwksData.keys && Array.isArray(jwksData.keys)) { + jwks = jwksData; + break; } else { + authLogger.error( + `Invalid JWKS structure from ${url}: ${JSON.stringify(jwksData)}`, + ); } - } catch { - continue; + } else { } + } catch { + continue; } + } if (!jwks) { throw new Error("Failed to fetch JWKS from any URL"); @@ -917,8 +917,7 @@ router.post("/login", async (req, res) => { if (kekSalt.length === 0) { await authManager.registerUser(userRecord.id, password); } - } catch { - } + } catch {} const dataUnlocked = await authManager.authenticateUser( userRecord.id, @@ -1153,7 +1152,7 @@ router.patch("/password-login-allowed", authenticateJWT, async (req, res) => { } db.$client .prepare( - "UPDATE settings SET value = ? WHERE key = 'allow_password_login'", + "INSERT OR REPLACE INTO settings (key, value) VALUES ('allow_password_login', ?)", ) .run(allowed ? "true" : "false"); res.json({ allowed }); diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 9f3d83cb..1fce11f7 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -375,7 +375,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { scheduleSessionCleanup(sessionId); res.json({ status: "success", message: "SSH connection established" }); - // Log activity to homepage API + // Log activity to dashboard API if (hostId && userId) { (async () => { try { @@ -446,6 +446,8 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { cleanupSession(sessionId); }); + let keyboardInteractiveResponded = false; + client.on( "keyboard-interactive", ( @@ -455,11 +457,14 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { prompts: Array<{ prompt: string; echo: boolean }>, finish: (responses: string[]) => void, ) => { + const promptTexts = prompts.map((p) => p.prompt); fileLogger.info("Keyboard-interactive authentication requested", { operation: "file_keyboard_interactive", hostId, sessionId, promptsCount: prompts.length, + prompts: promptTexts, + alreadyResponded: keyboardInteractiveResponded, }); const totpPromptIndex = prompts.findIndex((p) => @@ -469,7 +474,15 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { ); if (totpPromptIndex !== -1) { - if (responseSent) return; + // TOTP prompt detected - need user input + if (responseSent) { + fileLogger.warn("Response already sent, ignoring TOTP prompt", { + operation: "file_keyboard_interactive", + hostId, + sessionId, + }); + return; + } responseSent = true; if (pendingTOTPSessions[sessionId]) { @@ -481,11 +494,11 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { sessionId, }, ); - // Don't respond to duplicate keyboard-interactive events - // The first one is still being processed return; } + keyboardInteractiveResponded = true; + pendingTOTPSessions[sessionId] = { client, finish, @@ -516,14 +529,40 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { prompt: prompts[totpPromptIndex].prompt, }); } else { - if (resolvedCredentials.password) { - const responses = prompts.map( - () => resolvedCredentials.password || "", + // Non-TOTP prompts (password, etc.) - respond automatically + if (keyboardInteractiveResponded) { + fileLogger.warn( + "Already responded to keyboard-interactive, ignoring subsequent prompt", + { + operation: "file_keyboard_interactive", + hostId, + sessionId, + prompts: promptTexts, + }, ); - finish(responses); - } else { - finish(prompts.map(() => "")); + return; } + + keyboardInteractiveResponded = true; + + const responses = prompts.map((p) => { + if (/password/i.test(p.prompt) && resolvedCredentials.password) { + return resolvedCredentials.password; + } + return ""; + }); + + fileLogger.info("Auto-responding to keyboard-interactive prompts", { + operation: "file_keyboard_interactive_response", + hostId, + sessionId, + hasPassword: !!resolvedCredentials.password, + responsesProvided: responses.filter((r) => r !== "").length, + totalPrompts: prompts.length, + prompts: promptTexts, + }); + + finish(responses); } }, ); @@ -640,7 +679,7 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { message: "TOTP verified, SSH connection established", }); - // Log activity to homepage API + // Log activity to dashboard API if (session.hostId && session.userId) { (async () => { try { diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 8c90f15a..66481401 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -196,6 +196,7 @@ wss.on("connection", async (ws: WebSocket, req) => { let pingInterval: NodeJS.Timeout | null = null; let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null; let totpPromptSent = false; + let keyboardInteractiveResponded = false; ws.on("close", () => { const userWs = userConnections.get(userId); @@ -482,7 +483,29 @@ wss.on("connection", async (ws: WebSocket, req) => { // Small delay to let connection stabilize after keyboard-interactive auth // This helps prevent "No response from server" errors with TOTP setTimeout(() => { - sshConn!.shell( + // Check if connection still exists (might have been cleaned up) + if (!sshConn) { + sshLogger.warn( + "SSH connection was cleaned up before shell could be created", + { + operation: "ssh_shell", + hostId: id, + ip, + port, + username, + }, + ); + ws.send( + JSON.stringify({ + type: "error", + message: + "SSH connection was closed before terminal could be created", + }), + ); + return; + } + + sshConn.shell( { rows: data.rows, cols: data.cols, @@ -570,7 +593,7 @@ wss.on("connection", async (ws: WebSocket, req) => { JSON.stringify({ type: "connected", message: "SSH connected" }), ); - // Log activity to homepage API + // Log activity to dashboard API if (id && hostConfig.userId) { (async () => { try { @@ -714,8 +737,16 @@ wss.on("connection", async (ws: WebSocket, req) => { ); if (totpPromptIndex !== -1) { - if (totpPromptSent) return; + // TOTP prompt detected - need user input + if (totpPromptSent) { + sshLogger.warn("TOTP prompt already sent, ignoring duplicate", { + operation: "ssh_keyboard_interactive", + hostId: id, + }); + return; + } totpPromptSent = true; + keyboardInteractiveResponded = true; keyboardInteractiveFinish = (totpResponses: string[]) => { const totpCode = (totpResponses[0] || "").trim(); @@ -748,6 +779,20 @@ wss.on("connection", async (ws: WebSocket, req) => { }), ); } else { + // Non-TOTP prompts (password, etc.) - respond automatically + if (keyboardInteractiveResponded) { + sshLogger.warn( + "Already responded to keyboard-interactive, ignoring subsequent prompt", + { + operation: "ssh_keyboard_interactive", + hostId: id, + prompts: promptTexts, + }, + ); + return; + } + keyboardInteractiveResponded = true; + const responses = prompts.map((p) => { if (/password/i.test(p.prompt) && resolvedCredentials.password) { return resolvedCredentials.password; @@ -761,6 +806,7 @@ wss.on("connection", async (ws: WebSocket, req) => { hasPassword: !!resolvedCredentials.password, responsesProvided: responses.filter((r) => r !== "").length, totalPrompts: prompts.length, + prompts: promptTexts, }); console.log( @@ -948,6 +994,7 @@ wss.on("connection", async (ws: WebSocket, req) => { } totpPromptSent = false; + keyboardInteractiveResponded = false; keyboardInteractiveFinish = null; } diff --git a/src/backend/starter.ts b/src/backend/starter.ts index ad8b3120..db78f71e 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -104,7 +104,7 @@ import { systemLogger, versionLogger } from "./utils/logger.js"; await import("./ssh/tunnel.js"); await import("./ssh/file-manager.js"); await import("./ssh/server-stats.js"); - await import("./homepage.js"); + await import("./dashboard.js"); process.on("SIGINT", () => { systemLogger.info( diff --git a/src/backend/utils/data-crypto.ts b/src/backend/utils/data-crypto.ts index 6761cc5b..579e1960 100644 --- a/src/backend/utils/data-crypto.ts +++ b/src/backend/utils/data-crypto.ts @@ -125,13 +125,17 @@ class DataCrypto { if (needsUpdate) { const updateQuery = ` UPDATE ssh_data - SET password = ?, key = ?, key_password = ?, updated_at = CURRENT_TIMESTAMP + SET password = ?, key = ?, key_password = ?, key_type = ?, autostart_password = ?, autostart_key = ?, autostart_key_password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `; db.prepare(updateQuery).run( updatedRecord.password || null, updatedRecord.key || null, - updatedRecord.key_password || null, + updatedRecord.key_password || updatedRecord.keyPassword || null, + updatedRecord.keyType || null, + updatedRecord.autostartPassword || null, + updatedRecord.autostartKey || null, + updatedRecord.autostartKeyPassword || null, record.id, ); @@ -160,15 +164,16 @@ class DataCrypto { if (needsUpdate) { const updateQuery = ` UPDATE ssh_credentials - SET password = ?, key = ?, key_password = ?, private_key = ?, public_key = ?, updated_at = CURRENT_TIMESTAMP + SET password = ?, key = ?, key_password = ?, private_key = ?, public_key = ?, key_type = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `; db.prepare(updateQuery).run( updatedRecord.password || null, updatedRecord.key || null, - updatedRecord.key_password || null, - updatedRecord.private_key || null, - updatedRecord.public_key || null, + updatedRecord.key_password || updatedRecord.keyPassword || null, + updatedRecord.private_key || updatedRecord.privateKey || null, + updatedRecord.public_key || updatedRecord.publicKey || null, + updatedRecord.keyType || null, record.id, ); @@ -197,12 +202,18 @@ class DataCrypto { if (needsUpdate) { const updateQuery = ` UPDATE users - SET totp_secret = ?, totp_backup_codes = ? + SET totp_secret = ?, totp_backup_codes = ?, client_secret = ?, oidc_identifier = ? WHERE id = ? `; db.prepare(updateQuery).run( - updatedRecord.totp_secret || null, - updatedRecord.totp_backup_codes || null, + updatedRecord.totp_secret || updatedRecord.totpSecret || null, + updatedRecord.totp_backup_codes || + updatedRecord.totpBackupCodes || + null, + updatedRecord.client_secret || updatedRecord.clientSecret || null, + updatedRecord.oidc_identifier || + updatedRecord.oidcIdentifier || + null, userId, ); @@ -249,24 +260,44 @@ class DataCrypto { try { const tablesToReencrypt = [ - { table: "ssh_data", fields: ["password", "key", "key_password"] }, + { + table: "ssh_data", + fields: [ + "password", + "key", + "key_password", + "keyPassword", + "keyType", + "autostartPassword", + "autostartKey", + "autostartKeyPassword", + ], + }, { table: "ssh_credentials", fields: [ "password", "private_key", + "privateKey", "key_password", + "keyPassword", "key", "public_key", + "publicKey", + "keyType", ], }, { table: "users", fields: [ "client_secret", + "clientSecret", "totp_secret", + "totpSecret", "totp_backup_codes", + "totpBackupCodes", "oidc_identifier", + "oidcIdentifier", ], }, ]; diff --git a/src/backend/utils/field-crypto.ts b/src/backend/utils/field-crypto.ts index 78221220..824df007 100644 --- a/src/backend/utils/field-crypto.ts +++ b/src/backend/utils/field-crypto.ts @@ -17,19 +17,36 @@ class FieldCrypto { private static readonly ENCRYPTED_FIELDS = { users: new Set([ "password_hash", + "passwordHash", "client_secret", + "clientSecret", "totp_secret", + "totpSecret", "totp_backup_codes", + "totpBackupCodes", "oidc_identifier", "oidcIdentifier", ]), - ssh_data: new Set(["password", "key", "key_password", "keyPassword"]), + ssh_data: new Set([ + "password", + "key", + "key_password", + "keyPassword", + "keyType", + "autostartPassword", + "autostartKey", + "autostartKeyPassword", + ]), ssh_credentials: new Set([ "password", "private_key", + "privateKey", "key_password", + "keyPassword", "key", "public_key", + "publicKey", + "keyType", ]), }; diff --git a/src/backend/utils/logger.ts b/src/backend/utils/logger.ts index 3f986399..41f44982 100644 --- a/src/backend/utils/logger.ts +++ b/src/backend/utils/logger.ts @@ -253,6 +253,6 @@ export const apiLogger = new Logger("API", "🌐", "#3b82f6"); export const authLogger = new Logger("AUTH", "🔐", "#ef4444"); export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6"); export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6"); -export const homepageLogger = new Logger("HOMEPAGE", "🏠", "#ec4899"); +export const dashboardLogger = new Logger("DASHBOARD", "📊", "#ec4899"); export const logger = systemLogger; diff --git a/src/lib/frontend-logger.ts b/src/lib/frontend-logger.ts index 367c4060..2a314430 100644 --- a/src/lib/frontend-logger.ts +++ b/src/lib/frontend-logger.ts @@ -379,6 +379,6 @@ export const tunnelLogger = new FrontendLogger("TUNNEL", "📡", "#1e3a8a"); export const fileLogger = new FrontendLogger("FILE", "📁", "#1e3a8a"); export const statsLogger = new FrontendLogger("STATS", "📊", "#22c55e"); export const systemLogger = new FrontendLogger("SYSTEM", "🚀", "#1e3a8a"); -export const homepageLogger = new FrontendLogger("HOMEPAGE", "🏠", "#ec4899"); +export const dashboardLogger = new FrontendLogger("DASHBOARD", "📊", "#ec4899"); export const logger = systemLogger; diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 3ab3dd62..aa742128 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -1158,6 +1158,7 @@ "maxLength": "Die maximale Länge beträgt {{max}}", "invalidEmail": "Ungültige E-Mail-Adresse", "passwordMismatch": "Passwörter stimmen nicht überein", + "passwordLoginDisabled": "Benutzername/Passwort-Anmeldung ist derzeit deaktiviert", "weakPassword": "Das Passwort ist zu schwach", "usernameExists": "Benutzername existiert bereits", "emailExists": "E-Mail existiert bereits", @@ -1381,5 +1382,38 @@ "mobileAppInProgressDesc": "Wir arbeiten an einer speziellen mobilen App, um ein besseres Erlebnis auf Mobilgeräten zu bieten.", "viewMobileAppDocs": "Mobile App installieren", "mobileAppDocumentation": "Mobile App-Dokumentation" + }, + "dashboard": { + "title": "Dashboard", + "github": "GitHub", + "support": "Support", + "discord": "Discord", + "donate": "Spenden", + "serverOverview": "Serverübersicht", + "version": "Version", + "upToDate": "Auf dem neuesten Stand", + "updateAvailable": "Update verfügbar", + "uptime": "Betriebszeit", + "database": "Datenbank", + "healthy": "Gesund", + "error": "Fehler", + "totalServers": "Server gesamt", + "totalTunnels": "Tunnel gesamt", + "totalCredentials": "Anmeldedaten gesamt", + "recentActivity": "Kürzliche Aktivität", + "reset": "Zurücksetzen", + "loadingRecentActivity": "Kürzliche Aktivität wird geladen...", + "noRecentActivity": "Keine kürzliche Aktivität", + "quickActions": "Schnellaktionen", + "addHost": "Host hinzufügen", + "addCredential": "Anmeldedaten hinzufügen", + "adminSettings": "Admin-Einstellungen", + "userProfile": "Benutzerprofil", + "serverStats": "Serverstatistiken", + "loadingServerStats": "Serverstatistiken werden geladen...", + "noServerData": "Keine Serverdaten verfügbar", + "cpu": "CPU", + "ram": "RAM", + "notAvailable": "Nicht verfügbar" } } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 62abc045..003c215a 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1290,6 +1290,7 @@ "maxLength": "Maximum length is {{max}}", "invalidEmail": "Invalid email address", "passwordMismatch": "Passwords do not match", + "passwordLoginDisabled": "Username/password login is currently disabled", "weakPassword": "Password is too weak", "usernameExists": "Username already exists", "emailExists": "Email already exists", @@ -1514,5 +1515,38 @@ "mobileAppInProgressDesc": "We're working on a dedicated mobile app to provide a better experience on mobile devices.", "viewMobileAppDocs": "Install Mobile App", "mobileAppDocumentation": "Mobile App Documentation" + }, + "dashboard": { + "title": "Dashboard", + "github": "GitHub", + "support": "Support", + "discord": "Discord", + "donate": "Donate", + "serverOverview": "Server Overview", + "version": "Version", + "upToDate": "Up to Date", + "updateAvailable": "Update Available", + "uptime": "Uptime", + "database": "Database", + "healthy": "Healthy", + "error": "Error", + "totalServers": "Total Servers", + "totalTunnels": "Total Tunnels", + "totalCredentials": "Total Credentials", + "recentActivity": "Recent Activity", + "reset": "Reset", + "loadingRecentActivity": "Loading recent activity...", + "noRecentActivity": "No recent activity", + "quickActions": "Quick Actions", + "addHost": "Add Host", + "addCredential": "Add Credential", + "adminSettings": "Admin Settings", + "userProfile": "User Profile", + "serverStats": "Server Stats", + "loadingServerStats": "Loading server stats...", + "noServerData": "No server data available", + "cpu": "CPU", + "ram": "RAM", + "notAvailable": "N/A" } } diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 606d2728..a62e7bc0 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -1270,6 +1270,7 @@ "maxLength": "最大长度为 {{max}}", "invalidEmail": "邮箱地址无效", "passwordMismatch": "密码不匹配", + "passwordLoginDisabled": "用户名/密码登录当前已禁用", "weakPassword": "密码强度太弱", "usernameExists": "用户名已存在", "emailExists": "邮箱已存在", @@ -1424,5 +1425,38 @@ "mobileAppInProgressDesc": "我们正在开发专门的移动应用,为移动设备提供更好的体验。", "viewMobileAppDocs": "安装移动应用", "mobileAppDocumentation": "移动应用文档" + }, + "dashboard": { + "title": "仪表板", + "github": "GitHub", + "support": "支持", + "discord": "Discord", + "donate": "捐赠", + "serverOverview": "服务器概览", + "version": "版本", + "upToDate": "已是最新", + "updateAvailable": "有可用更新", + "uptime": "运行时间", + "database": "数据库", + "healthy": "健康", + "error": "错误", + "totalServers": "服务器总数", + "totalTunnels": "隧道总数", + "totalCredentials": "凭据总数", + "recentActivity": "最近活动", + "reset": "重置", + "loadingRecentActivity": "正在加载最近活动...", + "noRecentActivity": "无最近活动", + "quickActions": "快速操作", + "addHost": "添加主机", + "addCredential": "添加凭据", + "adminSettings": "管理员设置", + "userProfile": "用户资料", + "serverStats": "服务器统计", + "loadingServerStats": "正在加载服务器统计...", + "noServerData": "无可用服务器数据", + "cpu": "CPU", + "ram": "内存", + "notAvailable": "不可用" } } diff --git a/src/ui/Desktop/Apps/Dashboard/Dashboard.tsx b/src/ui/Desktop/Apps/Dashboard/Dashboard.tsx index ced98ced..0256afdc 100644 --- a/src/ui/Desktop/Apps/Dashboard/Dashboard.tsx +++ b/src/ui/Desktop/Apps/Dashboard/Dashboard.tsx @@ -32,9 +32,13 @@ import { UserPlus, Settings, User, + Loader2, + Terminal, + FolderOpen, } from "lucide-react"; import { Status } from "@/components/ui/shadcn-io/status"; import { BsLightning } from "react-icons/bs"; +import { useTranslation } from "react-i18next"; interface DashboardProps { onSelectView: (view: string) => void; @@ -55,6 +59,7 @@ export function Dashboard({ isTopbarOpen, onSelectView, }: DashboardProps): React.ReactElement { + const { t } = useTranslation(); const [loggedIn, setLoggedIn] = useState(isAuthenticated); const [isAdmin, setIsAdmin] = useState(false); const [, setUsername] = useState(null); @@ -74,9 +79,12 @@ export function Dashboard({ const [recentActivity, setRecentActivity] = useState( [], ); + const [recentActivityLoading, setRecentActivityLoading] = + useState(true); const [serverStats, setServerStats] = useState< Array<{ id: number; name: string; cpu: number | null; ram: number | null }> >([]); + const [serverStatsLoading, setServerStatsLoading] = useState(true); const { addTab, setCurrentTab, tabs: tabList } = useTabs(); @@ -165,7 +173,10 @@ export function Dashboard({ for (const host of hosts) { if (host.tunnelConnections) { try { - const tunnelConnections = JSON.parse(host.tunnelConnections); + // tunnelConnections is already parsed as an array from the backend + const tunnelConnections = Array.isArray(host.tunnelConnections) + ? host.tunnelConnections + : JSON.parse(host.tunnelConnections); if (Array.isArray(tunnelConnections)) { totalTunnelsCount += tunnelConnections.length; } @@ -180,10 +191,13 @@ export function Dashboard({ setTotalCredentials(credentials.length); // Fetch recent activity (35 items) + setRecentActivityLoading(true); const activity = await getRecentActivity(35); setRecentActivity(activity); + setRecentActivityLoading(false); // Fetch server stats for first 5 servers + setServerStatsLoading(true); const serversWithStats = await Promise.all( hosts.slice(0, 5).map(async (host: { id: number; name: string }) => { try { @@ -205,8 +219,11 @@ export function Dashboard({ }), ); setServerStats(serversWithStats); + setServerStatsLoading(false); } catch (error) { console.error("Failed to fetch dashboard data:", error); + setRecentActivityLoading(false); + setServerStatsLoading(false); } }; @@ -328,7 +345,9 @@ export function Dashboard({ >
-
Dashboard
+
+ {t("dashboard.title")} +
@@ -386,7 +405,7 @@ export function Dashboard({

- Server Overview + {t("dashboard.serverOverview")}

@@ -396,7 +415,9 @@ export function Dashboard({ color="#FFFFFF" className="shrink-0" /> -

Version

+

+ {t("dashboard.version")} +

@@ -409,8 +430,8 @@ export function Dashboard({ className={`ml-2 text-sm border-1 border-dark-border ${versionStatus === "up_to_date" ? "text-green-400" : "text-yellow-400"}`} > {versionStatus === "up_to_date" - ? "Up to Date" - : "Update Available"} + ? t("dashboard.upToDate") + : t("dashboard.updateAvailable")}
@@ -423,7 +444,9 @@ export function Dashboard({ color="#FFFFFF" className="shrink-0" /> -

Uptime

+

+ {t("dashboard.uptime")} +

@@ -440,14 +463,18 @@ export function Dashboard({ color="#FFFFFF" className="shrink-0" /> -

Database

+

+ {t("dashboard.database")} +

- {dbHealth} + {dbHealth === "healthy" + ? t("dashboard.healthy") + : t("dashboard.error")}

@@ -460,7 +487,9 @@ export function Dashboard({ color="#FFFFFF" className="mr-3 shrink-0" /> -

Total Servers

+

+ {t("dashboard.totalServers")} +

{totalServers} @@ -473,7 +502,9 @@ export function Dashboard({ color="#FFFFFF" className="mr-3 shrink-0" /> -

Total Tunnels

+

+ {t("dashboard.totalTunnels")} +

{totalTunnels} @@ -488,7 +519,9 @@ export function Dashboard({ color="#FFFFFF" className="mr-3 shrink-0" /> -

Total Credentials

+

+ {t("dashboard.totalCredentials")} +

{totalCredentials} @@ -502,7 +535,7 @@ export function Dashboard({

- Recent Activity + {t("dashboard.recentActivity")}

- {recentActivity.length === 0 ? ( + {recentActivityLoading ? ( +
+ + {t("dashboard.loadingRecentActivity")} +
+ ) : recentActivity.length === 0 ? (

- No recent activity + {t("dashboard.noRecentActivity")}

) : ( recentActivity.map((item) => ( @@ -526,7 +564,11 @@ export function Dashboard({ className="border-2 !border-dark-border bg-dark-bg" onClick={() => handleActivityClick(item)} > - + {item.type === "terminal" ? ( + + ) : ( + + )}

{item.hostName}

@@ -542,7 +584,7 @@ export function Dashboard({

- Quick Actions + {t("dashboard.quickActions")}

{isAdmin && ( @@ -582,7 +624,7 @@ export function Dashboard({ style={{ width: "40px", height: "40px" }} /> - Admin Settings + {t("dashboard.adminSettings")} )} @@ -596,7 +638,7 @@ export function Dashboard({ style={{ width: "40px", height: "40px" }} /> - User Profile + {t("dashboard.userProfile")}
@@ -606,12 +648,17 @@ export function Dashboard({

- Server Stats + {t("dashboard.serverStats")}

- {serverStats.length === 0 ? ( + {serverStatsLoading ? ( +
+ + {t("dashboard.loadingServerStats")} +
+ ) : serverStats.length === 0 ? (

- No server data available + {t("dashboard.noServerData")}

) : ( serverStats.map((server) => ( @@ -629,16 +676,16 @@ export function Dashboard({
- CPU:{" "} + {t("dashboard.cpu")}:{" "} {server.cpu !== null ? `${server.cpu}%` - : "N/A"} + : t("dashboard.notAvailable")} - RAM:{" "} + {t("dashboard.ram")}:{" "} {server.ram !== null ? `${server.ram}%` - : "N/A"} + : t("dashboard.notAvailable")}
diff --git a/src/ui/Desktop/Apps/File Manager/FileManager.tsx b/src/ui/Desktop/Apps/File Manager/FileManager.tsx index bb1b5842..544c7c47 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManager.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManager.tsx @@ -220,6 +220,34 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { const pathChangeTimerRef = useRef(null); const currentLoadingPathRef = useRef(""); const keepaliveTimerRef = useRef(null); + const activityLoggedRef = useRef(false); + const activityLoggingRef = useRef(false); // Prevent concurrent logging calls + + // Centralized activity logging to prevent duplicates + const logFileManagerActivity = useCallback(async () => { + if ( + !currentHost?.id || + activityLoggedRef.current || + activityLoggingRef.current + ) { + return; + } + + activityLoggingRef.current = true; + activityLoggedRef.current = true; + + try { + const hostName = + currentHost.name || `${currentHost.username}@${currentHost.ip}`; + await logActivity("file_manager", currentHost.id, hostName); + } catch (err) { + console.warn("Failed to log file manager activity:", err); + // Reset on error so it can be retried + activityLoggedRef.current = false; + } finally { + activityLoggingRef.current = false; + } + }, [currentHost]); const handleFileDragStart = useCallback( (files: FileItem[]) => { @@ -299,15 +327,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { setSshSessionId(sessionId); - // Log activity for recent connections - if (currentHost?.id) { - const hostName = - currentHost.name || `${currentHost.username}@${currentHost.ip}`; - logActivity("file_manager", currentHost.id, hostName).catch((err) => { - console.warn("Failed to log file manager activity:", err); - }); - } - try { const response = await listSSHFiles(sessionId, currentPath); const files = Array.isArray(response) @@ -316,6 +335,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { setFiles(files); clearSelection(); initialLoadDoneRef.current = true; + + // Log activity for recent connections (after successful directory load) + logFileManagerActivity(); } catch (dirError: unknown) { console.error("Failed to load initial directory:", dirError); } @@ -1257,15 +1279,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { setSshSessionId(totpSessionId); setTotpSessionId(null); - // Log activity for recent connections - if (currentHost?.id) { - const hostName = - currentHost.name || `${currentHost.username}@${currentHost.ip}`; - logActivity("file_manager", currentHost.id, hostName).catch((err) => { - console.warn("Failed to log file manager activity:", err); - }); - } - try { const response = await listSSHFiles(totpSessionId, currentPath); const files = Array.isArray(response) @@ -1275,6 +1288,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { clearSelection(); initialLoadDoneRef.current = true; toast.success(t("fileManager.connectedSuccessfully")); + + // Log activity for recent connections (after successful directory load) + logFileManagerActivity(); } catch (dirError: unknown) { console.error("Failed to load initial directory:", dirError); } diff --git a/src/ui/Desktop/Apps/Server/Server.tsx b/src/ui/Desktop/Apps/Server/Server.tsx index 600bae5b..5603935e 100644 --- a/src/ui/Desktop/Apps/Server/Server.tsx +++ b/src/ui/Desktop/Apps/Server/Server.tsx @@ -447,7 +447,6 @@ export function Server({
)} - {/* SSH Tunnels */} {currentHostConfig?.tunnelConnections && currentHostConfig.tunnelConnections.length > 0 && (
diff --git a/src/ui/Desktop/Apps/Terminal/Terminal.tsx b/src/ui/Desktop/Apps/Terminal/Terminal.tsx index 8484c6a1..9003f93c 100644 --- a/src/ui/Desktop/Apps/Terminal/Terminal.tsx +++ b/src/ui/Desktop/Apps/Terminal/Terminal.tsx @@ -93,12 +93,40 @@ export const Terminal = forwardRef( const isReconnectingRef = useRef(false); const isConnectingRef = useRef(false); const connectionTimeoutRef = useRef(null); + const activityLoggedRef = useRef(false); + const activityLoggingRef = useRef(false); // Prevent concurrent logging calls const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null); const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null); const notifyTimerRef = useRef(null); const DEBOUNCE_MS = 140; + // Centralized activity logging to prevent duplicates + const logTerminalActivity = async () => { + if ( + !hostConfig.id || + activityLoggedRef.current || + activityLoggingRef.current + ) { + return; + } + + activityLoggingRef.current = true; + activityLoggedRef.current = true; + + try { + const hostName = + hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`; + await logActivity("terminal", hostConfig.id, hostName); + } catch (err) { + console.warn("Failed to log terminal activity:", err); + // Reset on error so it can be retried + activityLoggedRef.current = false; + } finally { + activityLoggingRef.current = false; + } + }; + useEffect(() => { isVisibleRef.current = isVisible; }, [isVisible]); @@ -471,13 +499,7 @@ export const Terminal = forwardRef( isReconnectingRef.current = false; // Log activity for recent connections - if (hostConfig.id) { - const hostName = - hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`; - logActivity("terminal", hostConfig.id, hostName).catch((err) => { - console.warn("Failed to log terminal activity:", err); - }); - } + logTerminalActivity(); } else if (msg.type === "disconnected") { wasDisconnectedBySSH.current = true; setIsConnected(false); diff --git a/src/ui/Desktop/Authentication/Auth.tsx b/src/ui/Desktop/Authentication/Auth.tsx index bb5e449a..920948c9 100644 --- a/src/ui/Desktop/Authentication/Auth.tsx +++ b/src/ui/Desktop/Authentication/Auth.tsx @@ -12,6 +12,7 @@ import { loginUser, getUserInfo, getRegistrationAllowed, + getPasswordLoginAllowed, getOIDCConfig, getSetupRequired, initiatePasswordReset, @@ -65,6 +66,7 @@ export function Auth({ const [firstUser, setFirstUser] = useState(false); const [firstUserToastShown, setFirstUserToastShown] = useState(false); const [registrationAllowed, setRegistrationAllowed] = useState(true); + const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true); const [oidcConfigured, setOidcConfigured] = useState(false); const [resetStep, setResetStep] = useState< @@ -104,6 +106,18 @@ export function Auth({ }); }, []); + useEffect(() => { + getPasswordLoginAllowed() + .then((res) => { + setPasswordLoginAllowed(res.allowed); + }) + .catch((err) => { + if (err.code !== "NO_SERVER_CONFIGURED") { + console.error("Failed to fetch password login status:", err); + } + }); + }, []); + useEffect(() => { getOIDCConfig() .then((response) => { @@ -153,6 +167,12 @@ export function Auth({ } }, [registrationAllowed, internalLoggedIn, t]); + useEffect(() => { + if (!passwordLoginAllowed && oidcConfigured && tab !== "external") { + setTab("external"); + } + }, [passwordLoginAllowed, oidcConfigured, tab]); + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setLoading(true); @@ -163,6 +183,12 @@ export function Auth({ return; } + if (!passwordLoginAllowed && !firstUser) { + toast.error(t("errors.passwordLoginDisabled")); + setLoading(false); + return; + } + try { let res; if (tab === "login") { @@ -697,42 +723,46 @@ export function Auth({ {!loggedIn && !authLoading && !totpRequired && ( <>
- - + {passwordLoginAllowed && ( + + )} + {passwordLoginAllowed && ( + + )} {oidcConfigured && ( {host.enableTerminal && ( )} + + + + + + + + + addTab({ type: "server", title, hostConfig: host }) + } + > + Open Server Details + + + addTab({ type: "file_manager", title, hostConfig: host }) + } + > + Open File Manager + + alert("Settings clicked")}> + Edit + + +
+ {hasTags && (
{tags.map((tag: string) => ( diff --git a/src/ui/Desktop/Navigation/TopNavbar.tsx b/src/ui/Desktop/Navigation/TopNavbar.tsx index cc50dee5..ae418620 100644 --- a/src/ui/Desktop/Navigation/TopNavbar.tsx +++ b/src/ui/Desktop/Navigation/TopNavbar.tsx @@ -276,44 +276,97 @@ export function TopNavbar({ ...prev, currentX: e.clientX, })); + }; - // Calculate target position based on mouse X - if (!containerRef.current) return; + const calculateTargetIndex = () => { + if (!containerRef.current || dragState.draggedIndex === null) return null; - const containerRect = containerRef.current.getBoundingClientRect(); - const mouseX = e.clientX - containerRect.left; + const draggedIndex = dragState.draggedIndex; + // Build array of tab boundaries in ORIGINAL order + const tabBoundaries: { + index: number; + start: number; + end: number; + mid: number; + }[] = []; let accumulatedX = 0; - let newTargetIndex = dragState.draggedIndex; tabs.forEach((tab, i) => { const tabEl = tabRefs.current.get(i); if (!tabEl) return; const tabWidth = tabEl.getBoundingClientRect().width; - const tabCenter = accumulatedX + tabWidth / 2; - - if (mouseX < tabCenter && i === 0) { - newTargetIndex = 0; - } else if (mouseX >= tabCenter && mouseX < accumulatedX + tabWidth) { - newTargetIndex = i; - } - + tabBoundaries.push({ + index: i, + start: accumulatedX, + end: accumulatedX + tabWidth, + mid: accumulatedX + tabWidth / 2, + }); accumulatedX += tabWidth + 4; // 4px gap }); - if (mouseX >= accumulatedX - 4) { - newTargetIndex = tabs.length - 1; + if (tabBoundaries.length === 0) return null; + + // Calculate the dragged tab's center in container coordinates + const containerRect = containerRef.current.getBoundingClientRect(); + const draggedTab = tabBoundaries[draggedIndex]; + // Convert absolute positions to container-relative coordinates + const currentX = dragState.currentX - containerRect.left; + const startX = dragState.startX - containerRect.left; + const offset = currentX - startX; + const draggedCenter = draggedTab.mid + offset; + + // Determine target index based on where the dragged tab's center is + let newTargetIndex = draggedIndex; + + if (offset < 0) { + // Moving left - find the leftmost tab whose midpoint we've passed + for (let i = draggedIndex - 1; i >= 0; i--) { + if (draggedCenter < tabBoundaries[i].mid) { + newTargetIndex = i; + } else { + break; + } + } + } else if (offset > 0) { + // Moving right - find the rightmost tab whose midpoint we've passed + for (let i = draggedIndex + 1; i < tabBoundaries.length; i++) { + if (draggedCenter > tabBoundaries[i].mid) { + newTargetIndex = i; + } else { + break; + } + } } - setDragState((prev) => ({ - ...prev, - targetIndex: newTargetIndex, - })); + return newTargetIndex; }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); + + // Firefox compatibility - track position via dragover + if (dragState.draggedIndex === null) return; + + const containerRect = containerRef.current?.getBoundingClientRect(); + if (!containerRect) return; + + // Update currentX if we have a valid clientX (Firefox may not provide it in onDrag) + if (e.clientX !== 0) { + setDragState((prev) => ({ + ...prev, + currentX: e.clientX, + })); + } + + const newTargetIndex = calculateTargetIndex(); + if (newTargetIndex !== null && newTargetIndex !== dragState.targetIndex) { + setDragState((prev) => ({ + ...prev, + targetIndex: newTargetIndex, + })); + } }; const handleDrop = (e: React.DragEvent) => { @@ -326,14 +379,26 @@ export function TopNavbar({ dragState.draggedIndex !== dragState.targetIndex ) { reorderTabs(dragState.draggedIndex, dragState.targetIndex); - } - setDragState({ - draggedIndex: null, - startX: 0, - currentX: 0, - targetIndex: null, - }); + // Delay clearing drag state to prevent visual jitter + // This allows the reorder to complete and re-render before removing transforms + setTimeout(() => { + setDragState({ + draggedIndex: null, + startX: 0, + currentX: 0, + targetIndex: null, + }); + }, 0); + } else { + // No reorder needed, clear immediately + setDragState({ + draggedIndex: null, + startX: 0, + currentX: 0, + targetIndex: null, + }); + } }; const handleDragEnd = () => { @@ -345,14 +410,25 @@ export function TopNavbar({ dragState.draggedIndex !== dragState.targetIndex ) { reorderTabs(dragState.draggedIndex, dragState.targetIndex); - } - setDragState({ - draggedIndex: null, - startX: 0, - currentX: 0, - targetIndex: null, - }); + // Delay clearing drag state to prevent visual jitter + setTimeout(() => { + setDragState({ + draggedIndex: null, + startX: 0, + currentX: 0, + targetIndex: null, + }); + }, 0); + } else { + // No reorder needed, clear immediately + setDragState({ + draggedIndex: null, + startX: 0, + currentX: 0, + targetIndex: null, + }); + } }; const isSplitScreenActive = diff --git a/src/ui/Mobile/Authentication/Auth.tsx b/src/ui/Mobile/Authentication/Auth.tsx index b71f8b30..8626ea4c 100644 --- a/src/ui/Mobile/Authentication/Auth.tsx +++ b/src/ui/Mobile/Authentication/Auth.tsx @@ -12,6 +12,7 @@ import { loginUser, getUserInfo, getRegistrationAllowed, + getPasswordLoginAllowed, getOIDCConfig, getSetupRequired, initiatePasswordReset, @@ -67,6 +68,7 @@ export function Auth({ const [firstUser, setFirstUser] = useState(false); const [firstUserToastShown, setFirstUserToastShown] = useState(false); const [registrationAllowed, setRegistrationAllowed] = useState(true); + const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true); const [oidcConfigured, setOidcConfigured] = useState(false); const [resetStep, setResetStep] = useState< @@ -106,12 +108,30 @@ export function Auth({ }); }, []); + useEffect(() => { + getPasswordLoginAllowed() + .then((res) => { + setPasswordLoginAllowed(res.allowed); + }) + .catch((err) => { + if (err.code !== "NO_SERVER_CONFIGURED") { + console.error("Failed to fetch password login status:", err); + } + }); + }, []); + useEffect(() => { if (!registrationAllowed && !internalLoggedIn) { toast.warning(t("messages.registrationDisabled")); } }, [registrationAllowed, internalLoggedIn, t]); + useEffect(() => { + if (!passwordLoginAllowed && oidcConfigured && tab !== "external") { + setTab("external"); + } + }, [passwordLoginAllowed, oidcConfigured, tab]); + useEffect(() => { getOIDCConfig() .then((response) => { @@ -161,6 +181,12 @@ export function Auth({ return; } + if (!passwordLoginAllowed && !firstUser) { + toast.error(t("errors.passwordLoginDisabled")); + setLoading(false); + return; + } + try { let res; if (tab === "login") { @@ -595,42 +621,46 @@ export function Auth({ {!internalLoggedIn && !authLoading && !totpRequired && ( <>
- - + {passwordLoginAllowed && ( + + )} + {passwordLoginAllowed && ( + + )} {oidcConfigured && (