From 8028e5d7cb293a694d26cdf572446a4f9a461437 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 12 Nov 2025 00:58:02 -0600 Subject: [PATCH] feat: General bug fixes, added server stat commands, improved split screen, link accounts, etc --- DOWNLOADS.md | 61 --- src/backend/database/db/index.ts | 3 +- src/backend/database/db/schema.ts | 2 + src/backend/database/routes/credentials.ts | 12 +- src/backend/database/routes/snippets.ts | 240 ++++++++++ src/backend/database/routes/ssh.ts | 13 + src/backend/database/routes/users.ts | 268 ++++++------ src/backend/ssh/server-stats.ts | 18 - src/locales/de/translation.json | 20 +- src/locales/en/translation.json | 65 ++- src/locales/fr/translation.json | 18 + src/locales/pt-BR/translation.json | 18 + src/locales/ru/translation.json | 18 + src/locales/zh/translation.json | 16 + src/types/index.ts | 28 ++ src/ui/desktop/DesktopApp.tsx | 7 +- src/ui/desktop/admin/AdminSettings.tsx | 227 +++++----- .../apps/command-palette/CommandPalette.tsx | 172 ++++---- src/ui/desktop/apps/dashboard/Dashboard.tsx | 2 +- .../desktop/apps/host-manager/HostManager.tsx | 2 +- .../apps/host-manager/HostManagerEditor.tsx | 200 ++++++++- .../apps/host-manager/HostManagerViewer.tsx | 3 + src/ui/desktop/apps/server/Server.tsx | 179 ++++++-- src/ui/desktop/apps/terminal/Terminal.tsx | 52 ++- .../command-history/CommandAutocomplete.tsx | 49 ++- src/ui/desktop/apps/tools/SSHToolsSidebar.tsx | 412 +++++++++++++++++- src/ui/desktop/authentication/Auth.tsx | 6 +- src/ui/desktop/navigation/AppView.tsx | 30 +- src/ui/desktop/navigation/TopNavbar.tsx | 40 +- src/ui/desktop/navigation/tabs/Tab.tsx | 7 +- src/ui/desktop/navigation/tabs/TabContext.tsx | 27 +- src/ui/desktop/user/UserProfile.tsx | 27 +- src/ui/main-axios.ts | 63 ++- src/ui/mobile/authentication/Auth.tsx | 7 +- 34 files changed, 1724 insertions(+), 588 deletions(-) delete mode 100644 DOWNLOADS.md diff --git a/DOWNLOADS.md b/DOWNLOADS.md deleted file mode 100644 index 9aab0077..00000000 --- a/DOWNLOADS.md +++ /dev/null @@ -1,61 +0,0 @@ -# Termix Download Links - -## Windows - -| Architecture | Type | Download Link | -| ------------ | -------- | ---------------------------------------------------------------------------------------------------------- | -| x64 | NSIS | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_nsis.exe) | -| x64 | MSI | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_msi.msi) | -| x64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_portable.zip) | -| ia32 | NSIS | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_nsis.exe) | -| ia32 | MSI | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_msi.msi) | -| ia32 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_portable.zip) | - -## Linux - -| Architecture | Type | Download Link | -| ------------ | -------- | --------------------------------------------------------------------------------------------------------------- | -| x64 | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_appimage.AppImage) | -| x64 | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_deb.deb) | -| x64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_portable.tar.gz) | -| arm64 | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_appimage.AppImage) | -| arm64 | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_deb.deb) | -| arm64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_portable.tar.gz) | -| armv7l | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_appimage.AppImage) | -| armv7l | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_deb.deb) | -| armv7l | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_portable.tar.gz) | - -## macOS - -| Architecture | Type | Download Link | -| ------------ | ------------- | -------------------------------------------------------------------------------------------------------- | -| Universal | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_universal_dmg.dmg) | -| Universal | Mac App Store | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_universal_mas.pkg) | -| x64 | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_x64_dmg.dmg) | -| arm64 | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_arm64_dmg.dmg) | - ---- - -## All Platforms - Complete Download List - -| Platform | Architecture | Type | Download Link | -| -------- | ------------ | ------------- | --------------------------------------------------------------------------------------------------------------- | -| Windows | x64 | NSIS | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_nsis.exe) | -| Windows | x64 | MSI | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_msi.msi) | -| Windows | x64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_portable.zip) | -| Windows | ia32 | NSIS | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_nsis.exe) | -| Windows | ia32 | MSI | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_msi.msi) | -| Windows | ia32 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_portable.zip) | -| Linux | x64 | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_appimage.AppImage) | -| Linux | x64 | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_deb.deb) | -| Linux | x64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_portable.tar.gz) | -| Linux | arm64 | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_appimage.AppImage) | -| Linux | arm64 | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_deb.deb) | -| Linux | arm64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_portable.tar.gz) | -| Linux | armv7l | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_appimage.AppImage) | -| Linux | armv7l | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_deb.deb) | -| Linux | armv7l | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_portable.tar.gz) | -| macOS | Universal | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_universal_dmg.dmg) | -| macOS | Universal | Mac App Store | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_universal_mas.pkg) | -| macOS | x64 | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_x64_dmg.dmg) | -| macOS | arm64 | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_arm64_dmg.dmg) | diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 43f6e5e9..b297e667 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -316,7 +316,7 @@ async function initializeCompleteDatabase(): Promise { user_id TEXT NOT NULL, type TEXT NOT NULL, host_id INTEGER NOT NULL, - host_name TEXT NOT NULL, + host_name TEXT, timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE @@ -489,6 +489,7 @@ const migrateSchema = () => { addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT"); addColumnIfNotExists("ssh_data", "stats_config", "TEXT"); addColumnIfNotExists("ssh_data", "terminal_config", "TEXT"); + addColumnIfNotExists("ssh_data", "quick_actions", "TEXT"); addColumnIfNotExists("ssh_credentials", "private_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "public_key", "TEXT"); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 8fdf9e97..b8676b5f 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -89,6 +89,7 @@ export const sshData = sqliteTable("ssh_data", { defaultPath: text("default_path"), statsConfig: text("stats_config"), terminalConfig: text("terminal_config"), + quickActions: text("quick_actions"), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), @@ -238,6 +239,7 @@ export const recentActivity = sqliteTable("recent_activity", { hostId: integer("host_id") .notNull() .references(() => sshData.id, { onDelete: "cascade" }), + hostName: text("host_name"), timestamp: text("timestamp") .notNull() .default(sql`CURRENT_TIMESTAMP`), diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index 2f0c3ce4..70858694 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -524,6 +524,8 @@ router.delete( return res.status(404).json({ error: "Credential not found" }); } + // Update hosts using this credential to set credentialId to null + // This prevents orphaned references before deletion const hostsUsingCredential = await db .select() .from(sshData) @@ -552,14 +554,8 @@ router.delete( ); } - await db - .delete(sshCredentialUsage) - .where( - and( - eq(sshCredentialUsage.credentialId, parseInt(id)), - eq(sshCredentialUsage.userId, userId), - ), - ); + // sshCredentialUsage will be automatically deleted by ON DELETE CASCADE + // No need for manual deletion await db .delete(sshCredentials) diff --git a/src/backend/database/routes/snippets.ts b/src/backend/database/routes/snippets.ts index 89dc4513..2147160f 100644 --- a/src/backend/database/routes/snippets.ts +++ b/src/backend/database/routes/snippets.ts @@ -257,4 +257,244 @@ router.delete( }, ); +// Execute a snippet on a host +// POST /snippets/execute +router.post( + "/execute", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { snippetId, hostId } = req.body; + + if (!isNonEmptyString(userId) || !snippetId || !hostId) { + authLogger.warn("Invalid snippet execution request", { + userId, + snippetId, + hostId, + }); + return res + .status(400) + .json({ error: "Snippet ID and Host ID are required" }); + } + + try { + // Get the snippet + const snippetResult = await db + .select() + .from(snippets) + .where( + and( + eq(snippets.id, parseInt(snippetId)), + eq(snippets.userId, userId), + ), + ); + + if (snippetResult.length === 0) { + return res.status(404).json({ error: "Snippet not found" }); + } + + const snippet = snippetResult[0]; + + // Import SSH connection utilities + const { Client } = await import("ssh2"); + const { sshData, sshCredentials } = await import("../db/schema.js"); + + // Get host configuration + const hostResult = await db + .select() + .from(sshData) + .where( + and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)), + ); + + if (hostResult.length === 0) { + return res.status(404).json({ error: "Host not found" }); + } + + const host = hostResult[0]; + + // Resolve credentials if needed + let password = host.password; + let privateKey = host.key; + let passphrase = host.key_password; + + if (host.credentialId) { + const credResult = await db + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, host.credentialId), + eq(sshCredentials.userId, userId), + ), + ); + + if (credResult.length > 0) { + const cred = credResult[0]; + password = cred.password || undefined; + privateKey = cred.private_key || cred.key || undefined; + passphrase = cred.key_password || undefined; + } + } + + // Create SSH connection + const conn = new Client(); + let output = ""; + let errorOutput = ""; + + const executePromise = new Promise<{ + success: boolean; + output: string; + error?: string; + }>((resolve, reject) => { + const timeout = setTimeout(() => { + conn.end(); + reject(new Error("Command execution timeout (30s)")); + }, 30000); + + conn.on("ready", () => { + conn.exec(snippet.content, (err, stream) => { + if (err) { + clearTimeout(timeout); + conn.end(); + return reject(err); + } + + stream.on("close", () => { + clearTimeout(timeout); + conn.end(); + if (errorOutput) { + resolve({ success: false, output, error: errorOutput }); + } else { + resolve({ success: true, output }); + } + }); + + stream.on("data", (data: Buffer) => { + output += data.toString(); + }); + + stream.stderr.on("data", (data: Buffer) => { + errorOutput += data.toString(); + }); + }); + }); + + conn.on("error", (err) => { + clearTimeout(timeout); + reject(err); + }); + + // Connect to SSH + const config: any = { + host: host.ip, + port: host.port, + username: host.username, + tryKeyboard: true, + keepaliveInterval: 30000, + keepaliveCountMax: 3, + readyTimeout: 30000, + tcpKeepAlive: true, + tcpKeepAliveInitialDelay: 30000, + timeout: 30000, + env: { + TERM: "xterm-256color", + LANG: "en_US.UTF-8", + LC_ALL: "en_US.UTF-8", + LC_CTYPE: "en_US.UTF-8", + LC_MESSAGES: "en_US.UTF-8", + LC_MONETARY: "en_US.UTF-8", + LC_NUMERIC: "en_US.UTF-8", + LC_TIME: "en_US.UTF-8", + LC_COLLATE: "en_US.UTF-8", + COLORTERM: "truecolor", + }, + algorithms: { + kex: [ + "curve25519-sha256", + "curve25519-sha256@libssh.org", + "ecdh-sha2-nistp521", + "ecdh-sha2-nistp384", + "ecdh-sha2-nistp256", + "diffie-hellman-group-exchange-sha256", + "diffie-hellman-group14-sha256", + "diffie-hellman-group14-sha1", + "diffie-hellman-group-exchange-sha1", + "diffie-hellman-group1-sha1", + ], + serverHostKey: [ + "ssh-ed25519", + "ecdsa-sha2-nistp521", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp256", + "rsa-sha2-512", + "rsa-sha2-256", + "ssh-rsa", + "ssh-dss", + ], + cipher: [ + "chacha20-poly1305@openssh.com", + "aes256-gcm@openssh.com", + "aes128-gcm@openssh.com", + "aes256-ctr", + "aes192-ctr", + "aes128-ctr", + "aes256-cbc", + "aes192-cbc", + "aes128-cbc", + "3des-cbc", + ], + hmac: [ + "hmac-sha2-512-etm@openssh.com", + "hmac-sha2-256-etm@openssh.com", + "hmac-sha2-512", + "hmac-sha2-256", + "hmac-sha1", + "hmac-md5", + ], + compress: ["none", "zlib@openssh.com", "zlib"], + }, + }; + + if (password) { + config.password = password; + } + + if (privateKey) { + const cleanKey = privateKey + .trim() + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + config.privateKey = Buffer.from(cleanKey, "utf8"); + if (passphrase) { + config.passphrase = passphrase; + } + } + + conn.connect(config); + }); + + const result = await executePromise; + + authLogger.success( + `Snippet executed: ${snippet.name} on host ${hostId}`, + { + operation: "snippet_execute_success", + userId, + snippetId, + hostId, + }, + ); + + res.json(result); + } catch (err) { + authLogger.error("Failed to execute snippet", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to execute snippet", + }); + } + }, +); + export default router; diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index a6432f1e..388e008b 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -237,6 +237,7 @@ router.post( defaultPath, tunnelConnections, jumpHosts, + quickActions, statsConfig, terminalConfig, forceKeyboardInteractive, @@ -274,6 +275,9 @@ router.post( ? JSON.stringify(tunnelConnections) : null, jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null, + quickActions: Array.isArray(quickActions) + ? JSON.stringify(quickActions) + : null, enableFileManager: enableFileManager ? 1 : 0, defaultPath: defaultPath || null, statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, @@ -456,6 +460,7 @@ router.put( defaultPath, tunnelConnections, jumpHosts, + quickActions, statsConfig, terminalConfig, forceKeyboardInteractive, @@ -494,6 +499,9 @@ router.put( ? JSON.stringify(tunnelConnections) : null, jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null, + quickActions: Array.isArray(quickActions) + ? JSON.stringify(quickActions) + : null, enableFileManager: enableFileManager ? 1 : 0, defaultPath: defaultPath || null, statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, @@ -672,6 +680,9 @@ router.get( ? JSON.parse(row.tunnelConnections as string) : [], jumpHosts: row.jumpHosts ? JSON.parse(row.jumpHosts as string) : [], + quickActions: row.quickActions + ? JSON.parse(row.quickActions as string) + : [], enableFileManager: !!row.enableFileManager, statsConfig: row.statsConfig ? JSON.parse(row.statsConfig as string) @@ -745,6 +756,8 @@ router.get( tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [], + jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts) : [], + quickActions: host.quickActions ? JSON.parse(host.quickActions) : [], enableFileManager: !!host.enableFileManager, statsConfig: host.statsConfig ? JSON.parse(host.statsConfig) diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 46fa5097..2e187e2f 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -762,6 +762,44 @@ router.get("/oidc/callback", async (req, res) => { .get(); isFirstUser = ((countResult as { count?: number })?.count || 0) === 0; + // Check if registration is allowed (unless this is the first user) + if (!isFirstUser) { + try { + const regRow = db.$client + .prepare( + "SELECT value FROM settings WHERE key = 'allow_registration'", + ) + .get(); + if (regRow && (regRow as Record).value !== "true") { + authLogger.warn( + "OIDC user attempted to register when registration is disabled", + { + operation: "oidc_registration_disabled", + identifier, + name, + }, + ); + + let frontendUrl = (redirectUri as string).replace( + "/users/oidc/callback", + "", + ); + if (frontendUrl.includes("localhost")) { + frontendUrl = "http://localhost:5173"; + } + const redirectUrl = new URL(frontendUrl); + redirectUrl.searchParams.set("error", "registration_disabled"); + + return res.redirect(redirectUrl.toString()); + } + } catch (e) { + authLogger.warn("Failed to check registration status during OIDC", { + operation: "oidc_registration_check", + error: e, + }); + } + } + const id = nanoid(); await db.insert(users).values({ id, @@ -1681,6 +1719,7 @@ router.get("/list", authenticateJWT, async (req, res) => { username: users.username, is_admin: users.is_admin, is_oidc: users.is_oidc, + password_hash: users.password_hash, }) .from(users); @@ -2517,21 +2556,15 @@ router.post("/sessions/revoke-all", authenticateJWT, async (req, res) => { } }); -// Route: Convert OIDC user to password user (link accounts) -// POST /users/convert-oidc-to-password -router.post("/convert-oidc-to-password", authenticateJWT, async (req, res) => { +// Route: Link OIDC user to existing password account (merge accounts) +// POST /users/link-oidc-to-password +router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => { const adminUserId = (req as AuthenticatedRequest).userId; - const { targetUserId, newPassword, totpCode } = req.body; + const { oidcUserId, targetUsername } = req.body; - if (!isNonEmptyString(targetUserId) || !isNonEmptyString(newPassword)) { + if (!isNonEmptyString(oidcUserId) || !isNonEmptyString(targetUsername)) { return res.status(400).json({ - error: "Target user ID and new password are required", - }); - } - - if (newPassword.length < 8) { - return res.status(400).json({ - error: "New password must be at least 8 characters long", + error: "OIDC user ID and target username are required", }); } @@ -2545,185 +2578,128 @@ router.post("/convert-oidc-to-password", authenticateJWT, async (req, res) => { return res.status(403).json({ error: "Admin access required" }); } - // Get target user + // Get OIDC user + 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]; + + // Verify user is OIDC + if (!oidcUser.is_oidc) { + return res.status(400).json({ + error: "Source user is not an OIDC user", + }); + } + + // Get target password user const targetUserRecords = await db .select() .from(users) - .where(eq(users.id, targetUserId)); + .where(eq(users.username, targetUsername)); if (!targetUserRecords || targetUserRecords.length === 0) { - return res.status(404).json({ error: "Target user not found" }); + return res.status(404).json({ error: "Target password user not found" }); } const targetUser = targetUserRecords[0]; - // Verify user is OIDC - if (!targetUser.is_oidc) { + // Verify target user has password authentication + if (targetUser.is_oidc || !targetUser.password_hash) { return res.status(400).json({ - error: "User is already a password-based user", + error: "Target user must be a password-based account", }); } - // Verify TOTP if enabled - if (targetUser.totp_enabled && targetUser.totp_secret) { - if (!totpCode) { - return res.status(400).json({ - error: "TOTP code required for this user", - code: "TOTP_REQUIRED", - }); - } - - const verified = speakeasy.totp.verify({ - secret: targetUser.totp_secret, - encoding: "base32", - token: totpCode, - window: 2, + // Check if target user already has OIDC configured + if (targetUser.client_id && targetUser.oidc_identifier) { + return res.status(400).json({ + error: "Target user already has OIDC authentication configured", }); - - if (!verified) { - return res.status(401).json({ error: "Invalid TOTP code" }); - } } - authLogger.info("Converting OIDC user to password user", { - operation: "convert_oidc_to_password", - targetUserId, - adminUserId, + authLogger.info("Linking OIDC user to password account", { + operation: "link_oidc_to_password", + oidcUserId, + oidcUsername: oidcUser.username, + targetUserId: targetUser.id, targetUsername: targetUser.username, + adminUserId, }); - // Step 1: Get current DEK from memory (requires user to be logged in) - // For admin conversion, we need to authenticate as OIDC user first - const deviceType = "web"; - const unlocked = await authManager.authenticateOIDCUser( - targetUserId, - deviceType, - ); - - if (!unlocked) { - return res.status(500).json({ - error: "Failed to unlock user data for conversion", - }); - } - - // Get the DEK from memory - const { DataCrypto } = await import("../../utils/data-crypto.js"); - const currentDEK = DataCrypto.getUserDataKey(targetUserId); - - if (!currentDEK) { - return res.status(500).json({ - error: "User data encryption key not available", - }); - } - - // Step 2: Setup new password-based encryption - const { UserCrypto } = await import("../../utils/user-crypto.js"); - - // Generate new KEK from password - const kekSalt = crypto.randomBytes(32); - const kekSaltHex = kekSalt.toString("hex"); - - // Derive KEK from new password - const kek = await new Promise((resolve, reject) => { - crypto.pbkdf2( - newPassword, - kekSalt, - 100000, - 32, - "sha256", - (err, derivedKey) => { - if (err) reject(err); - else resolve(derivedKey); - }, - ); - }); - - // Encrypt the existing DEK with new password-derived KEK - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv("aes-256-gcm", kek, iv); - let encryptedDEK = cipher.update(currentDEK); - encryptedDEK = Buffer.concat([encryptedDEK, cipher.final()]); - const authTag = cipher.getAuthTag(); - - const encryptedDEKData = JSON.stringify({ - iv: iv.toString("hex"), - encryptedKey: encryptedDEK.toString("hex"), - authTag: authTag.toString("hex"), - }); - - // Step 3: Hash the new password - const saltRounds = parseInt(process.env.SALT || "10", 10); - const password_hash = await bcrypt.hash(newPassword, saltRounds); - - // Step 4: Update user record atomically + // Copy OIDC configuration from OIDC user to target password user await db .update(users) .set({ - password_hash, - is_oidc: false, - oidc_identifier: null, - client_id: "", - client_secret: "", - issuer_url: "", - authorization_url: "", - token_url: "", - identifier_path: "", - name_path: "", - scopes: "openid email profile", + is_oidc: true, // Enable OIDC login for this account + 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, targetUserId)); + .where(eq(users.id, targetUser.id)); - // Step 5: Update KEK salt and encrypted DEK in settings + // Revoke all sessions for the OIDC user before deletion + await authManager.revokeAllUserSessions(oidcUserId); + authManager.logoutUser(oidcUserId); + + // Delete OIDC user's recent activity first (to avoid NOT NULL constraint issues) + await db + .delete(recentActivity) + .where(eq(recentActivity.userId, oidcUserId)); + + // Delete the OIDC user (CASCADE will delete related data: hosts, credentials, sessions, etc.) + await db.delete(users).where(eq(users.id, oidcUserId)); + + // Clean up OIDC user's settings db.$client - .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") - .run(`user_kek_salt_${targetUserId}`, kekSaltHex); - - db.$client - .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") - .run(`user_encrypted_dek_${targetUserId}`, encryptedDEKData); - - // Step 6: Remove OIDC session duration setting if exists - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`user_oidc_session_duration_${targetUserId}`); - - // Step 7: Revoke all existing sessions to force re-login - await authManager.revokeAllUserSessions(targetUserId); - - // Step 8: Clear the in-memory DEK - authManager.logoutUser(targetUserId); + .prepare("DELETE FROM settings WHERE key LIKE ?") + .run(`user_%_${oidcUserId}`); try { const { saveMemoryDatabaseToFile } = await import("../db/index.js"); await saveMemoryDatabaseToFile(); } catch (saveError) { - authLogger.error("Failed to persist conversion to disk", saveError, { - operation: "convert_oidc_save_failed", - targetUserId, + authLogger.error("Failed to persist account linking to disk", saveError, { + operation: "link_oidc_save_failed", + oidcUserId, + targetUserId: targetUser.id, }); } authLogger.success( - `OIDC user converted to password user: ${targetUser.username}`, + `OIDC user ${oidcUser.username} linked to password account ${targetUser.username}`, { - operation: "convert_oidc_to_password_success", - targetUserId, - adminUserId, + operation: "link_oidc_to_password_success", + oidcUserId, + oidcUsername: oidcUser.username, + targetUserId: targetUser.id, targetUsername: targetUser.username, + adminUserId, }, ); res.json({ success: true, - message: `User ${targetUser.username} has been converted to password authentication. All sessions have been revoked.`, + 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 convert OIDC user to password user", err, { - operation: "convert_oidc_to_password_failed", - targetUserId, + 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 convert user account", + error: "Failed to link accounts", details: err instanceof Error ? err.message : "Unknown error", }); } diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index 2298a201..8d292710 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -704,15 +704,6 @@ class PollingManager { config.statusTimer = setInterval(() => { this.pollHostStatus(host); }, intervalMs); - - statsLogger.debug( - `Started status polling for host ${host.id} (interval: ${statsConfig.statusCheckInterval}s)`, - { - operation: "status_polling_started", - hostId: host.id, - interval: statsConfig.statusCheckInterval, - }, - ); } else { this.statusStore.delete(host.id); statsLogger.debug(`Status polling disabled for host ${host.id}`, { @@ -729,15 +720,6 @@ class PollingManager { config.metricsTimer = setInterval(() => { this.pollHostMetrics(host); }, intervalMs); - - statsLogger.debug( - `Started metrics polling for host ${host.id} (interval: ${statsConfig.metricsInterval}s)`, - { - operation: "metrics_polling_started", - hostId: host.id, - interval: statsConfig.metricsInterval, - }, - ); } else { this.metricsStore.delete(host.id); statsLogger.debug(`Metrics polling disabled for host ${host.id}`, { diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index a456c21e..674dc137 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -189,9 +189,23 @@ "enableRightClickCopyPaste": "Rechtsklick-Kopieren\/Einfügen aktivieren", "shareIdeas": "Haben Sie Vorschläge welche weiteren SSH-Tools ergänzt werden sollen? Dann teilen Sie diese gerne mit uns auf" }, + "commandHistory": { + "title": "Verlauf", + "searchPlaceholder": "Befehle suchen...", + "noTerminal": "Kein aktives Terminal", + "noTerminalHint": "Öffnen Sie ein Terminal, um dessen Befehlsverlauf anzuzeigen.", + "empty": "Noch kein Befehlsverlauf", + "emptyHint": "Führen Sie Befehle im aktiven Terminal aus, um einen Verlauf zu erstellen.", + "noResults": "Keine Befehle gefunden", + "noResultsHint": "Keine Befehle mit \"{{query}}\" gefunden", + "deleteSuccess": "Befehl aus Verlauf gelöscht", + "deleteFailed": "Befehl konnte nicht gelöscht werden.", + "deleteTooltip": "Befehl löschen", + "tabHint": "Verwenden Sie Tab im Terminal, um aus dem Befehlsverlauf zu vervollständigen" + }, "homepage": { "loggedInTitle": "Eingeloggt!", - "loggedInMessage": "Sie sind angemeldet! Über die Seitenleiste haben Sie Zugriff auf alle verfügbaren Tools. Erstellen Sie zunächst einen SSH-Host im Tab „SSH-Manager“. Anschließend können Sie sich über die anderen Apps in der Seitenleiste mit diesem Host verbinden.", + "loggedInMessage": "Sie sind angemeldet! Über die Seitenleiste haben Sie Zugriff auf alle verfügbaren Tools. Erstellen Sie zunächst einen SSH-Host im Tab SSH-Manager. Anschließend können Sie sich über die anderen Apps in der Seitenleiste mit diesem Host verbinden.", "failedToLoadAlerts": "Warnmeldungen konnten nicht geladen werden", "failedToDismissAlert": "Benachrichtigung konnte nicht geschlossen werden" }, @@ -1374,6 +1388,10 @@ "local": "Lokal", "external": "Extern (OIDC)", "selectPreferredLanguage": "Wählen Sie Ihre bevorzugte Sprache für die Benutzeroberfläche", + "fileColorCoding": "Dateifarb-Codierung", + "fileColorCodingDesc": "Farbcodierung von Dateien nach Typ: Ordner (rot), Dateien (blau), Symlinks (grün)", + "commandAutocomplete": "Befehlsautovervollständigung", + "commandAutocompleteDesc": "Tab-Taste Autovervollständigung für Terminal-Befehle basierend auf Ihrem Befehlsverlauf aktivieren", "currentPassword": "Aktuelles Passwort", "passwordChangedSuccess": "Passwort erfolgreich geändert! Bitte melden Sie sich erneut an.", "failedToChangePassword": "Passwort konnte nicht geändert werden. Bitte überprüfen Sie Ihr aktuelles Passwort und versuchen Sie es erneut." diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 34b1ee3e..618f9ff5 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -226,6 +226,20 @@ "editTooltip": "Edit this snippet", "deleteTooltip": "Delete this snippet" }, + "commandHistory": { + "title": "History", + "searchPlaceholder": "Search commands...", + "noTerminal": "No active terminal", + "noTerminalHint": "Open a terminal to see its command history.", + "empty": "No command history yet", + "emptyHint": "Execute commands in the active terminal to build its history.", + "noResults": "No commands found", + "noResultsHint": "No commands matching \"{{query}}\"", + "deleteSuccess": "Command deleted from history", + "deleteFailed": "Failed to delete command.", + "deleteTooltip": "Delete command", + "tabHint": "Use Tab in Terminal to autocomplete from command history" + }, "homepage": { "loggedInTitle": "Logged in!", "loggedInMessage": "You are logged in! Use the sidebar to access all available tools. To get started, create an SSH Host in the SSH Manager tab. Once created, you can connect to that host using the other apps in the sidebar.", @@ -482,26 +496,20 @@ "confirmRevokeAllSessions": "Are you sure you want to revoke all sessions for this user?", "failedToRevokeSessions": "Failed to revoke sessions", "sessionsRevokedSuccessfully": "Sessions revoked successfully", - "convertToPasswordAuth": "Convert to Password Authentication", - "convertOIDCToPassword": "Convert {{username}} from OIDC/SSO authentication to password-based authentication. This will allow the user to log in with a username and password instead of through an external provider.", - "convertUserDialogTitle": "Convert to Password Authentication", - "convertUserDialogDescription": "This action will set a new password, disable OIDC/SSO login, log out all sessions, and preserve all user data.", - "convertActionWillSetPassword": "Set a new password for this user", - "convertActionWillDisableOIDC": "Disable OIDC/SSO login for this account", - "convertActionWillLogout": "Log out all active sessions", - "convertActionWillPreserveData": "Preserve all user data (SSH hosts, credentials, etc.)", - "convertPasswordLabel": "New Password (min 8 chars)", - "convertPasswordPlaceholder": "Enter new password", - "convertTotpLabel": "TOTP Code (if user has 2FA enabled)", - "convertTotpPlaceholder": "000000", - "convertUserButton": "Convert User", - "convertingUser": "Converting...", - "userConvertedSuccessfully": "User {{username}} has been converted to password authentication. All sessions have been revoked.", - "failedToConvertUser": "Failed to convert user account", - "convertPasswordRequired": "Password is required", - "convertPasswordTooShort": "Password must be at least 8 characters long", - "convertTotpRequired": "TOTP code is required for this user", - "convertToPasswordTitle": "Convert to password authentication", + "linkToPasswordAccount": "Link to Password Account", + "linkOIDCDialogTitle": "Link OIDC Account to Password Account", + "linkOIDCDialogDescription": "Link {{username}} (OIDC user) to an existing password account. This will enable dual authentication for the password account.", + "linkOIDCWarningTitle": "Warning: OIDC User Data Will Be Deleted", + "linkOIDCActionDeleteUser": "Delete the OIDC user account and all their data", + "linkOIDCActionAddCapability": "Add OIDC login capability to the target password account", + "linkOIDCActionDualAuth": "Allow the password account to login with both password and OIDC", + "linkTargetUsernameLabel": "Target Password Account Username", + "linkTargetUsernamePlaceholder": "Enter username of password account", + "linkAccountsButton": "Link Accounts", + "linkingAccounts": "Linking...", + "accountsLinkedSuccessfully": "OIDC user {{oidcUsername}} has been linked to {{targetUsername}}", + "failedToLinkAccounts": "Failed to link accounts", + "linkTargetUsernameRequired": "Target username is required", "databaseSecurity": "Database Security", "encryptionStatus": "Encryption Status", "encryptionEnabled": "Encryption Enabled", @@ -889,6 +897,13 @@ "searchServers": "Search servers...", "noServerFound": "No server found", "jumpHostsOrder": "Connections will be made in order: Jump Host 1 → Jump Host 2 → ... → Target Server", + "quickActions": "Quick Actions", + "quickActionsDescription": "Quick actions allow you to create custom buttons that execute SSH snippets on this server. These buttons will appear at the top of the Server Stats page for quick access.", + "quickActionsList": "Quick Actions List", + "addQuickAction": "Add Quick Action", + "quickActionName": "Action name", + "noSnippetFound": "No snippet found", + "quickActionsOrder": "Quick action buttons will appear in the order listed above on the Server Stats page", "advancedAuthSettings": "Advanced Authentication Settings" }, "terminal": { @@ -1370,7 +1385,13 @@ "recentSuccessfulLogins": "Recent Successful Logins", "recentFailedAttempts": "Recent Failed Attempts", "noRecentLoginData": "No recent login data", - "from": "from" + "from": "from", + "quickActions": "Quick Actions", + "executeQuickAction": "Execute {{name}}", + "executingQuickAction": "Executing {{name}}...", + "quickActionSuccess": "{{name}} completed successfully", + "quickActionFailed": "{{name}} failed", + "quickActionError": "Failed to execute {{name}}" }, "auth": { "tagline": "SSH SERVER MANAGER", @@ -1553,6 +1574,8 @@ "selectPreferredLanguage": "Select your preferred language for the interface", "fileColorCoding": "File Color Coding", "fileColorCodingDesc": "Color-code files by type: folders (red), files (blue), symlinks (green)", + "commandAutocomplete": "Command Autocomplete", + "commandAutocompleteDesc": "Enable Tab key autocomplete suggestions for terminal commands based on your command history", "currentPassword": "Current Password", "passwordChangedSuccess": "Password changed successfully! Please log in again.", "failedToChangePassword": "Failed to change password. Please check your current password and try again." diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index bacbf87e..bf689514 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -223,6 +223,20 @@ "editTooltip": "Modifier cet extrait", "deleteTooltip": "Supprimer cet extrait" }, + "commandHistory": { + "title": "Historique", + "searchPlaceholder": "Rechercher des commandes...", + "noTerminal": "Aucun terminal actif", + "noTerminalHint": "Ouvrez un terminal pour voir son historique de commandes.", + "empty": "Aucun historique de commandes", + "emptyHint": "Exécutez des commandes dans le terminal actif pour créer un historique.", + "noResults": "Aucune commande trouvée", + "noResultsHint": "Aucune commande correspondant à \"{{query}}\"", + "deleteSuccess": "Commande supprimée de l'historique", + "deleteFailed": "Échec de la suppression de la commande.", + "deleteTooltip": "Supprimer la commande", + "tabHint": "Utilisez Tab dans le terminal pour compléter automatiquement depuis l'historique des commandes" + }, "homepage": { "loggedInTitle": "Connexion réussie !", "loggedInMessage": "Vous êtes connecté ! Utilisez la barre latérale pour accéder à tous les outils disponibles. Pour commencer, créez un hôte SSH dans l'onglet Gestionnaire SSH. Une fois créé, vous pourrez vous connecter à cet hôte avec les autres applications de la barre latérale.", @@ -1368,6 +1382,10 @@ "local": "Local", "external": "Externe (OIDC)", "selectPreferredLanguage": "Choisissez votre langue préférée pour l'interface", + "fileColorCoding": "Codage couleur des fichiers", + "fileColorCodingDesc": "Codage couleur des fichiers par type : dossiers (rouge), fichiers (bleu), liens symboliques (vert)", + "commandAutocomplete": "Autocomplétion des commandes", + "commandAutocompleteDesc": "Activer les suggestions d'autocomplétion avec la touche Tab pour les commandes du terminal basées sur votre historique", "currentPassword": "Mot de passe actuel", "passwordChangedSuccess": "Mot de passe modifié avec succès ! Veuillez vous reconnecter.", "failedToChangePassword": "Échec de la modification du mot de passe. Vérifiez votre mot de passe actuel et réessayez." diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index cd82e350..594a11fc 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -191,6 +191,20 @@ "enableRightClickCopyPaste": "Habilitar copiar/colar com botão direito", "shareIdeas": "Tem ideias sobre o que deve vir a seguir nas ferramentas SSH? Compartilhe em" }, + "commandHistory": { + "title": "Histórico", + "searchPlaceholder": "Pesquisar comandos...", + "noTerminal": "Nenhum terminal ativo", + "noTerminalHint": "Abra um terminal para ver seu histórico de comandos.", + "empty": "Ainda não há histórico de comandos", + "emptyHint": "Execute comandos no terminal ativo para criar um histórico.", + "noResults": "Nenhum comando encontrado", + "noResultsHint": "Nenhum comando correspondente a \"{{query}}\"", + "deleteSuccess": "Comando removido do histórico", + "deleteFailed": "Falha ao excluir comando.", + "deleteTooltip": "Excluir comando", + "tabHint": "Use Tab no Terminal para autocompletar do histórico de comandos" + }, "homepage": { "loggedInTitle": "Conectado!", "loggedInMessage": "Você está conectado! Use a barra lateral para acessar todas as ferramentas disponíveis. Para começar, crie um Host SSH na aba Gerenciador SSH. Depois de criado, você pode se conectar a esse host usando os outros apps na barra lateral.", @@ -1322,6 +1336,10 @@ "local": "Local", "external": "Externo (OIDC)", "selectPreferredLanguage": "Selecione seu idioma preferido para a interface", + "fileColorCoding": "Codificação de Cores de Arquivos", + "fileColorCodingDesc": "Codificar arquivos por cores por tipo: pastas (vermelho), arquivos (azul), links simbólicos (verde)", + "commandAutocomplete": "Autocompletar Comandos", + "commandAutocompleteDesc": "Ativar sugestões de autocompletar com a tecla Tab para comandos do terminal baseado no seu histórico", "currentPassword": "Senha Atual", "passwordChangedSuccess": "Senha alterada com sucesso! Por favor, faça login novamente.", "failedToChangePassword": "Falha ao alterar a senha. Por favor, verifique sua senha atual e tente novamente." diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 06e1b25f..2abb1132 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -225,6 +225,20 @@ "editTooltip": "Редактировать этот сниппет", "deleteTooltip": "Удалить этот сниппет" }, + "commandHistory": { + "title": "История", + "searchPlaceholder": "Поиск команд...", + "noTerminal": "Нет активного терминала", + "noTerminalHint": "Откройте терминал, чтобы увидеть историю команд.", + "empty": "История команд пока пуста", + "emptyHint": "Выполните команды в активном терминале, чтобы создать историю.", + "noResults": "Команды не найдены", + "noResultsHint": "Нет команд, соответствующих \"{{query}}\"", + "deleteSuccess": "Команда удалена из истории", + "deleteFailed": "Не удалось удалить команду.", + "deleteTooltip": "Удалить команду", + "tabHint": "Используйте Tab в Терминале для автозаполнения из истории команд" + }, "homepage": { "loggedInTitle": "Вы вошли в систему!", "loggedInMessage": "Вы вошли в систему! Используйте боковую панель для доступа ко всем доступным инструментам. Чтобы начать, создайте SSH-хост в разделе SSH-менеджера. После создания вы можете подключиться к этому хосту, используя другие приложения на боковой панели.", @@ -1461,6 +1475,10 @@ "local": "Локальный", "external": "Внешний (OIDC)", "selectPreferredLanguage": "Выберите предпочитаемый язык интерфейса", + "fileColorCoding": "Цветовое кодирование файлов", + "fileColorCodingDesc": "Цветовая кодировка файлов по типу: папки (красный), файлы (синий), символические ссылки (зелёный)", + "commandAutocomplete": "Автодополнение команд", + "commandAutocompleteDesc": "Включить автодополнение команд терминала клавишей Tab на основе вашей истории команд", "currentPassword": "Текущий пароль", "passwordChangedSuccess": "Пароль успешно изменен! Пожалуйста, войдите снова.", "failedToChangePassword": "Не удалось изменить пароль. Пожалуйста, проверьте ваш текущий пароль и попробуйте снова." diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index dbba8fac..8c61b597 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -223,6 +223,20 @@ "editTooltip": "编辑此片段", "deleteTooltip": "删除此片段" }, + "commandHistory": { + "title": "历史记录", + "searchPlaceholder": "搜索命令...", + "noTerminal": "无活动终端", + "noTerminalHint": "打开终端以查看其命令历史记录。", + "empty": "暂无命令历史记录", + "emptyHint": "在活动终端中执行命令以建立历史记录。", + "noResults": "未找到命令", + "noResultsHint": "没有匹配 \"{{query}}\" 的命令", + "deleteSuccess": "命令已从历史记录中删除", + "deleteFailed": "删除命令失败。", + "deleteTooltip": "删除命令", + "tabHint": "在终端中使用 Tab 键从命令历史记录自动完成" + }, "homepage": { "loggedInTitle": "登录成功!", "loggedInMessage": "您已登录!使用侧边栏访问所有可用工具。要开始使用,请在 SSH 管理器选项卡中创建 SSH 主机。创建后,您可以使用侧边栏中的其他应用程序连接到该主机。", @@ -1494,6 +1508,8 @@ "selectPreferredLanguage": "选择您的界面首选语言", "fileColorCoding": "文件颜色编码", "fileColorCodingDesc": "按类型对文件进行颜色编码:文件夹(红色)、文件(蓝色)、符号链接(绿色)", + "commandAutocomplete": "命令自动补全", + "commandAutocompleteDesc": "启用基于命令历史记录的 Tab 键终端命令自动补全建议", "currentPassword": "当前密码", "passwordChangedSuccess": "密码修改成功!请重新登录。", "failedToChangePassword": "修改密码失败。请检查您当前的密码并重试。" diff --git a/src/types/index.ts b/src/types/index.ts index 06ac52c1..9bd159ed 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -9,6 +9,11 @@ export interface JumpHost { hostId: number; } +export interface QuickAction { + name: string; + snippetId: number; +} + export interface SSHHost { id: number; name: string; @@ -38,6 +43,7 @@ export interface SSHHost { defaultPath: string; tunnelConnections: TunnelConnection[]; jumpHosts?: JumpHost[]; + quickActions?: QuickAction[]; statsConfig?: string; terminalConfig?: TerminalConfig; createdAt: string; @@ -48,6 +54,11 @@ export interface JumpHostData { hostId: number; } +export interface QuickActionData { + name: string; + snippetId: number; +} + export interface SSHHostData { name?: string; ip: string; @@ -70,6 +81,7 @@ export interface SSHHostData { forceKeyboardInteractive?: boolean; tunnelConnections?: TunnelConnection[]; jumpHosts?: JumpHostData[]; + quickActions?: QuickActionData[]; statsConfig?: string | Record; terminalConfig?: TerminalConfig; } @@ -317,6 +329,22 @@ export interface TabContextTab { initialTab?: string; } +// Split Screen Layout Types +export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid"; + +export interface SplitConfiguration { + layout: SplitLayout; + positions: Map; // position index -> tab ID +} + +export interface SplitLayoutOption { + id: SplitLayout; + name: string; + description: string; + cellCount: number; + icon: string; // lucide icon name +} + // ============================================================================ // CONNECTION STATES // ============================================================================ diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index b77776ca..86bb2951 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -31,7 +31,7 @@ function AppContent() { const { currentTab, tabs } = useTabs(); const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); const [rightSidebarOpen, setRightSidebarOpen] = useState(false); - const [rightSidebarWidth, setRightSidebarWidth] = useState(300); + const [rightSidebarWidth, setRightSidebarWidth] = useState(400); const lastShiftPressTime = useRef(0); @@ -69,6 +69,8 @@ function AppContent() { setIsAuthenticated(false); setIsAdmin(false); setUsername(null); + // Clear invalid token + localStorage.removeItem("jwt"); } else { setIsAuthenticated(true); setIsAdmin(!!meRes.is_admin); @@ -80,6 +82,9 @@ function AppContent() { setIsAdmin(false); setUsername(null); + // Clear invalid token on any auth error + localStorage.removeItem("jwt"); + const errorCode = err?.response?.data?.code; if (errorCode === "SESSION_EXPIRED") { console.warn("Session expired - please log in again"); diff --git a/src/ui/desktop/admin/AdminSettings.tsx b/src/ui/desktop/admin/AdminSettings.tsx index 4619da5b..5d0999cf 100644 --- a/src/ui/desktop/admin/AdminSettings.tsx +++ b/src/ui/desktop/admin/AdminSettings.tsx @@ -34,7 +34,7 @@ import { Trash2, Users, Database, - Lock, + Link2, Download, Upload, Monitor, @@ -63,7 +63,7 @@ import { getSessions, revokeSession, revokeAllUserSessions, - convertOIDCToPassword, + linkOIDCToPasswordAccount, } from "@/ui/main-axios.ts"; interface AdminSettingsProps { @@ -75,7 +75,7 @@ interface AdminSettingsProps { export function AdminSettings({ isTopbarOpen = true, rightSidebarOpen = false, - rightSidebarWidth = 300, + rightSidebarWidth = 400, }: AdminSettingsProps): React.ReactElement { const { t } = useTranslation(); const { confirmWithToast } = useConfirmation(); @@ -107,6 +107,7 @@ export function AdminSettings({ username: string; is_admin: boolean; is_oidc: boolean; + password_hash?: string; }> >([]); const [usersLoading, setUsersLoading] = React.useState(false); @@ -147,15 +148,13 @@ export function AdminSettings({ >([]); const [sessionsLoading, setSessionsLoading] = React.useState(false); - const [convertUserDialogOpen, setConvertUserDialogOpen] = - React.useState(false); - const [convertTargetUser, setConvertTargetUser] = React.useState<{ + const [linkAccountAlertOpen, setLinkAccountAlertOpen] = React.useState(false); + const [linkOidcUser, setLinkOidcUser] = React.useState<{ id: string; username: string; } | null>(null); - const [convertPassword, setConvertPassword] = React.useState(""); - const [convertTotpCode, setConvertTotpCode] = React.useState(""); - const [convertLoading, setConvertLoading] = React.useState(false); + const [linkTargetUsername, setLinkTargetUsername] = React.useState(""); + const [linkLoading, setLinkLoading] = React.useState(false); const requiresImportPassword = React.useMemo( () => !currentUser?.is_oidc, @@ -655,54 +654,41 @@ export function AdminSettings({ ); }; - const handleConvertOIDCUser = (user: { id: string; username: string }) => { - setConvertTargetUser(user); - setConvertPassword(""); - setConvertTotpCode(""); - setConvertUserDialogOpen(true); + const handleLinkOIDCUser = (user: { id: string; username: string }) => { + setLinkOidcUser(user); + setLinkTargetUsername(""); + setLinkAccountAlertOpen(true); }; - const handleConvertSubmit = async () => { - if (!convertTargetUser || !convertPassword) { - toast.error("Password is required"); + const handleLinkSubmit = async () => { + if (!linkOidcUser || !linkTargetUsername.trim()) { + toast.error("Target username is required"); return; } - if (convertPassword.length < 8) { - toast.error("Password must be at least 8 characters long"); - return; - } - - setConvertLoading(true); + setLinkLoading(true); try { - const result = await convertOIDCToPassword( - convertTargetUser.id, - convertPassword, - convertTotpCode || undefined, + const result = await linkOIDCToPasswordAccount( + linkOidcUser.id, + linkTargetUsername.trim(), ); toast.success( result.message || - `User ${convertTargetUser.username} converted to password authentication`, + `OIDC user ${linkOidcUser.username} linked to ${linkTargetUsername}`, ); - setConvertUserDialogOpen(false); - setConvertPassword(""); - setConvertTotpCode(""); - setConvertTargetUser(null); + setLinkAccountAlertOpen(false); + setLinkTargetUsername(""); + setLinkOidcUser(null); fetchUsers(); + fetchSessions(); } catch (error: unknown) { const err = error as { response?: { data?: { error?: string; code?: string } }; }; - if (err.response?.data?.code === "TOTP_REQUIRED") { - toast.error("TOTP code is required for this user"); - } else { - toast.error( - err.response?.data?.error || "Failed to convert user account", - ); - } + toast.error(err.response?.data?.error || "Failed to link accounts"); } finally { - setConvertLoading(false); + setLinkLoading(false); } }; @@ -1095,26 +1081,28 @@ export function AdminSettings({ )} - {user.is_oidc - ? t("admin.external") - : t("admin.local")} + {user.is_oidc && user.password_hash + ? "Dual Auth" + : user.is_oidc + ? t("admin.external") + : t("admin.local")}
- {user.is_oidc && ( + {user.is_oidc && !user.password_hash && ( )}
- {/* Convert OIDC to Password Dialog */} - - - - Convert to Password Authentication - - Convert {convertTargetUser?.username} from OIDC/SSO authentication - to password-based authentication. This will allow the user to log - in with a username and password instead of through an external - provider. - - + {/* Link OIDC to Password Account Dialog */} + {linkAccountAlertOpen && ( + + + + + + Link OIDC Account to Password Account + + + Link{" "} + + {linkOidcUser?.username} + {" "} + (OIDC user) to an existing password account. This will enable + dual authentication for the password account. + + -
- - Important - - This action will: -
    -
  • Set a new password for this user
  • -
  • Disable OIDC/SSO login for this account
  • -
  • Log out all active sessions
  • -
  • Preserve all user data (SSH hosts, credentials, etc.)
  • -
-
-
+
+ + Warning: OIDC User Data Will Be Deleted + + This action will: +
    +
  • Delete the OIDC user account and all their data
  • +
  • + Add OIDC login capability to the target password account +
  • +
  • + Allow the password account to login with both password and + OIDC +
  • +
+
+
-
- - setConvertPassword(e.target.value)} - placeholder="Enter new password" - disabled={convertLoading} - /> +
+ + setLinkTargetUsername(e.target.value)} + placeholder="Enter username of password account" + disabled={linkLoading} + onKeyDown={(e) => { + if (e.key === "Enter" && linkTargetUsername.trim()) { + handleLinkSubmit(); + } + }} + /> +
-
- - setConvertTotpCode(e.target.value)} - placeholder="000000" - disabled={convertLoading} - maxLength={6} - /> -
-
- - - - - - -
+ + + + +
+
+ )} ); } diff --git a/src/ui/desktop/apps/command-palette/CommandPalette.tsx b/src/ui/desktop/apps/command-palette/CommandPalette.tsx index 2c1da720..7b42d5a3 100644 --- a/src/ui/desktop/apps/command-palette/CommandPalette.tsx +++ b/src/ui/desktop/apps/command-palette/CommandPalette.tsx @@ -122,7 +122,10 @@ export function CommandPalette({ if (adminTab) { setCurrentTab(adminTab.id); } else { - const id = addTab({ type: "admin", title: t("commandPalette.adminSettings") }); + const id = addTab({ + type: "admin", + title: t("commandPalette.adminSettings"), + }); setCurrentTab(id); } setIsOpen(false); @@ -133,7 +136,10 @@ export function CommandPalette({ if (userProfileTab) { setCurrentTab(userProfileTab.id); } else { - const id = addTab({ type: "user_profile", title: t("commandPalette.userProfile") }); + const id = addTab({ + type: "user_profile", + title: t("commandPalette.userProfile"), + }); setCurrentTab(id); } setIsOpen(false); @@ -288,83 +294,93 @@ export function CommandPalette({ - - {hosts.map((host, index) => { - const title = host.name?.trim() - ? host.name - : `${host.username}@${host.ip}:${host.port}`; - return ( - { - if (host.enableTerminal) { - handleHostTerminalClick(host); - } - }} - className="flex items-center justify-between" - > -
- - {title} -
-
e.stopPropagation()} - > - - - - - 0 && ( + <> + + {hosts.map((host, index) => { + const title = host.name?.trim() + ? host.name + : `${host.username}@${host.ip}:${host.port}`; + return ( + { + if (host.enableTerminal) { + handleHostTerminalClick(host); + } + }} + className="flex items-center justify-between" + > +
+ + {title} +
+
e.stopPropagation()} > - { - e.stopPropagation(); - handleHostServerDetailsClick(host); - }} - className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" - > - - {t("commandPalette.openServerDetails")} - - { - e.stopPropagation(); - handleHostFileManagerClick(host); - }} - className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" - > - - {t("commandPalette.openFileManager")} - - { - e.stopPropagation(); - handleHostEditClick(host); - }} - className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" - > - - {t("commandPalette.edit")} - - - -
-
- ); - })} -
- + + + + + + { + e.stopPropagation(); + handleHostServerDetailsClick(host); + }} + className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" + > + + + {t("commandPalette.openServerDetails")} + + + { + e.stopPropagation(); + handleHostFileManagerClick(host); + }} + className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" + > + + + {t("commandPalette.openFileManager")} + + + { + e.stopPropagation(); + handleHostEditClick(host); + }} + className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" + > + + + {t("commandPalette.edit")} + + + + +
+
+ ); + })} +
+ + + )} diff --git a/src/ui/desktop/apps/dashboard/Dashboard.tsx b/src/ui/desktop/apps/dashboard/Dashboard.tsx index 37c79697..f0000d9f 100644 --- a/src/ui/desktop/apps/dashboard/Dashboard.tsx +++ b/src/ui/desktop/apps/dashboard/Dashboard.tsx @@ -62,7 +62,7 @@ export function Dashboard({ isTopbarOpen, onSelectView, rightSidebarOpen = false, - rightSidebarWidth = 300, + rightSidebarWidth = 400, }: DashboardProps): React.ReactElement { const { t } = useTranslation(); const [loggedIn, setLoggedIn] = useState(isAuthenticated); diff --git a/src/ui/desktop/apps/host-manager/HostManager.tsx b/src/ui/desktop/apps/host-manager/HostManager.tsx index c801a9f2..01a4ec9c 100644 --- a/src/ui/desktop/apps/host-manager/HostManager.tsx +++ b/src/ui/desktop/apps/host-manager/HostManager.tsx @@ -19,7 +19,7 @@ export function HostManager({ initialTab = "host_viewer", hostConfig, rightSidebarOpen = false, - rightSidebarWidth = 300, + rightSidebarWidth = 400, }: HostManagerProps): React.ReactElement { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState(initialTab); diff --git a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx index e19f8ad0..9d9a96dc 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx @@ -110,12 +110,12 @@ function JumpHostItem({ {index + 1}. - + - + {t("hosts.noServerFound")} @@ -133,7 +136,7 @@ function JumpHostItem({ .map((host) => ( { onUpdate(host.id); setOpen(false); @@ -162,7 +165,112 @@ function JumpHostItem({ - + + ); +} + +interface QuickActionItemProps { + quickAction: { name: string; snippetId: number }; + index: number; + snippets: Array<{ id: number; name: string; content: string }>; + onUpdate: (name: string, snippetId: number) => void; + onRemove: () => void; + t: (key: string) => string; +} + +function QuickActionItem({ + quickAction, + index, + snippets, + onUpdate, + onRemove, + t, +}: QuickActionItemProps) { + const [open, setOpen] = React.useState(false); + const selectedSnippet = snippets.find((s) => s.id === quickAction.snippetId); + + return ( +
+
+
+ + {index + 1}. + + onUpdate(e.target.value, quickAction.snippetId)} + className="flex-1" + /> +
+ + + + + + + + {t("hosts.noSnippetFound")} + + {snippets.map((snippet) => ( + { + onUpdate(quickAction.name, snippet.id); + setOpen(false); + }} + > + +
+ {snippet.name} + + {snippet.content} + +
+
+ ))} +
+
+
+
+
+
@@ -198,6 +306,10 @@ interface SSHHost { jumpHosts?: Array<{ hostId: number; }>; + quickActions?: Array<{ + name: string; + snippetId: number; + }>; statsConfig?: StatsConfig; terminalConfig?: TerminalConfig; createdAt: string; @@ -440,6 +552,14 @@ export function HostManagerEditor({ }), ) .default([]), + quickActions: z + .array( + z.object({ + name: z.string().min(1), + snippetId: z.number().min(1), + }), + ) + .default([]), }) .superRefine((data, ctx) => { if (data.authType === "none") { @@ -528,6 +648,7 @@ export function HostManagerEditor({ defaultPath: "/", tunnelConnections: [], jumpHosts: [], + quickActions: [], statsConfig: DEFAULT_STATS_CONFIG, terminalConfig: DEFAULT_TERMINAL_CONFIG, forceKeyboardInteractive: false, @@ -612,6 +733,9 @@ export function HostManagerEditor({ jumpHosts: Array.isArray(cleanedHost.jumpHosts) ? cleanedHost.jumpHosts : [], + quickActions: Array.isArray(cleanedHost.quickActions) + ? cleanedHost.quickActions + : [], statsConfig: parsedStatsConfig, terminalConfig: { ...DEFAULT_TERMINAL_CONFIG, @@ -670,6 +794,7 @@ export function HostManagerEditor({ defaultPath: "/", tunnelConnections: [], jumpHosts: [], + quickActions: [], statsConfig: DEFAULT_STATS_CONFIG, terminalConfig: DEFAULT_TERMINAL_CONFIG, forceKeyboardInteractive: false, @@ -730,6 +855,7 @@ export function HostManagerEditor({ defaultPath: data.defaultPath || "/", tunnelConnections: data.tunnelConnections || [], jumpHosts: data.jumpHosts || [], + quickActions: data.quickActions || [], statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG, terminalConfig: data.terminalConfig || DEFAULT_TERMINAL_CONFIG, forceKeyboardInteractive: Boolean(data.forceKeyboardInteractive), @@ -2983,6 +3109,70 @@ export function HostManagerEditor({ /> )} + +
+

+ {t("hosts.quickActions")} +

+ + + {t("hosts.quickActionsDescription")} + + + ( + + {t("hosts.quickActionsList")} + +
+ {field.value.map((quickAction, index) => ( + { + const newQuickActions = [...field.value]; + newQuickActions[index] = { + name, + snippetId, + }; + field.onChange(newQuickActions); + }} + onRemove={() => { + const newQuickActions = field.value.filter( + (_, i) => i !== index, + ); + field.onChange(newQuickActions); + }} + t={t} + /> + ))} + +
+
+ + {t("hosts.quickActionsOrder")} + +
+ )} + /> +
diff --git a/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx b/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx index 94d3ff04..75ee9317 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx @@ -1240,6 +1240,9 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {

{host.username}

+

+ ID: {host.id} +

{host.folder && host.folder !== "" && ( diff --git a/src/ui/desktop/apps/server/Server.tsx b/src/ui/desktop/apps/server/Server.tsx index 3d29de7f..7ea1180e 100644 --- a/src/ui/desktop/apps/server/Server.tsx +++ b/src/ui/desktop/apps/server/Server.tsx @@ -7,6 +7,7 @@ import { Tunnel } from "@/ui/desktop/apps/tunnel/Tunnel.tsx"; import { getServerStatusById, getServerMetricsById, + executeSnippet, type ServerMetrics, } from "@/ui/main-axios.ts"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; @@ -29,6 +30,11 @@ import { } from "./widgets"; import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; +interface QuickAction { + name: string; + snippetId: number; +} + interface HostConfig { id: number; name: string; @@ -37,6 +43,7 @@ interface HostConfig { folder?: string; enableFileManager?: boolean; tunnelConnections?: unknown[]; + quickActions?: QuickAction[]; statsConfig?: string | StatsConfig; [key: string]: unknown; } @@ -81,6 +88,9 @@ export function Server({ const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false); const [isRefreshing, setIsRefreshing] = React.useState(false); const [showStatsUI, setShowStatsUI] = React.useState(true); + const [executingActions, setExecutingActions] = React.useState>( + new Set(), + ); const statsConfig = React.useMemo((): StatsConfig => { if (!currentHostConfig?.statsConfig) { @@ -450,38 +460,147 @@ export function Server({
- {metricsEnabled && showStatsUI && ( -
- {!metrics && serverStatus === "offline" ? ( -
-
-
-
-
-

- {t("serverStats.serverOffline")} -

-

- {t("serverStats.cannotFetchMetrics")} -

-
-
- ) : ( -
- {enabledWidgets.map((widgetType) => ( -
- {renderWidget(widgetType)} -
- ))} -
- )} + {(metricsEnabled && showStatsUI) || + (currentHostConfig?.quickActions && + currentHostConfig.quickActions.length > 0) ? ( +
+ {currentHostConfig?.quickActions && + currentHostConfig.quickActions.length > 0 && ( +
+

+ {t("serverStats.quickActions")} +

+
+ {currentHostConfig.quickActions.map((action, index) => { + const isExecuting = executingActions.has( + action.snippetId, + ); + return ( + + ); + })} +
+
+ )} + {metricsEnabled && + showStatsUI && + (!metrics && serverStatus === "offline" ? ( +
+
+
+
+
+

+ {t("serverStats.serverOffline")} +

+

+ {t("serverStats.cannotFetchMetrics")} +

+
+
+ ) : ( +
+ {enabledWidgets.map((widgetType) => ( +
+ {renderWidget(widgetType)} +
+ ))} +
+ ))} + + {metricsEnabled && showStatsUI && ( + + )}
- )} + ) : null} {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 20a7af98..a51bd2ad 100644 --- a/src/ui/desktop/apps/terminal/Terminal.tsx +++ b/src/ui/desktop/apps/terminal/Terminal.tsx @@ -221,7 +221,11 @@ export const Terminal = forwardRef( // Load command history for autocomplete on mount (Stage 3) useEffect(() => { - if (hostConfig.id) { + // Check if command autocomplete is enabled + const autocompleteEnabled = + localStorage.getItem("commandAutocomplete") !== "false"; + + if (hostConfig.id && autocompleteEnabled) { import("@/ui/main-axios.ts") .then((module) => module.getCommandHistory(hostConfig.id!)) .then((history) => { @@ -231,6 +235,8 @@ export const Terminal = forwardRef( console.error("Failed to load autocomplete history:", error); autocompleteHistory.current = []; }); + } else { + autocompleteHistory.current = []; } }, [hostConfig.id]); @@ -1318,6 +1324,20 @@ export const Terminal = forwardRef( e.preventDefault(); e.stopPropagation(); + // Check if command autocomplete is enabled in settings + const autocompleteEnabled = + localStorage.getItem("commandAutocomplete") !== "false"; + + if (!autocompleteEnabled) { + // If disabled, let the terminal handle Tab normally (send to server) + if (webSocketRef.current?.readyState === 1) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: "\t" }), + ); + } + return false; + } + const currentCmd = getCurrentCommandRef.current().trim(); if (currentCmd.length > 0 && webSocketRef.current?.readyState === 1) { // Filter commands that start with current input @@ -1328,7 +1348,7 @@ export const Terminal = forwardRef( cmd !== currentCmd && cmd.length > currentCmd.length, ) - .slice(0, 10); // Show up to 10 matches + .slice(0, 5); // Show up to 5 matches for better UX if (matches.length === 1) { // Only one match - auto-complete directly @@ -1359,21 +1379,31 @@ export const Terminal = forwardRef( const cellWidth = terminal.cols > 0 ? rect.width / terminal.cols : 10; - // Estimate autocomplete menu height (max-h-[240px] from component) - const menuHeight = 240; - const cursorBottomY = rect.top + (cursorY + 1) * cellHeight; - const spaceBelow = window.innerHeight - cursorBottomY; - const spaceAbove = rect.top + cursorY * cellHeight; + // Calculate actual menu height based on number of items + // Each item is ~32px (py-1.5), footer is ~32px, max total 240px + const itemHeight = 32; + const footerHeight = 32; + const maxMenuHeight = 240; + const estimatedMenuHeight = Math.min( + matches.length * itemHeight + footerHeight, + maxMenuHeight, + ); - // Show above cursor if not enough space below + // Get cursor position in viewport coordinates + const cursorBottomY = rect.top + (cursorY + 1) * cellHeight; + const cursorTopY = rect.top + cursorY * cellHeight; + const spaceBelow = window.innerHeight - cursorBottomY; + const spaceAbove = cursorTopY; + + // Show above cursor if not enough space below and more space above const showAbove = - spaceBelow < menuHeight && spaceAbove > spaceBelow; + spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow; setAutocompletePosition({ top: showAbove - ? rect.top + cursorY * cellHeight - menuHeight + ? Math.max(0, cursorTopY - estimatedMenuHeight) : cursorBottomY, - left: rect.left + cursorX * cellWidth, + left: Math.max(0, rect.left + cursorX * cellWidth), }); } diff --git a/src/ui/desktop/apps/terminal/command-history/CommandAutocomplete.tsx b/src/ui/desktop/apps/terminal/command-history/CommandAutocomplete.tsx index 8d0c907b..78a4b3d6 100644 --- a/src/ui/desktop/apps/terminal/command-history/CommandAutocomplete.tsx +++ b/src/ui/desktop/apps/terminal/command-history/CommandAutocomplete.tsx @@ -33,33 +33,44 @@ export function CommandAutocomplete({ return null; } + // Calculate max height for suggestions list to ensure footer is always visible + // Footer height is approximately 32px (text + padding + border) + const footerHeight = 32; + const maxSuggestionsHeight = 240 - footerHeight; + return (
- {suggestions.map((suggestion, index) => ( -
onSelect(suggestion)} - onMouseEnter={() => { - // Optional: update selected index on hover - }} - > - {suggestion} -
- ))} -
+
+ {suggestions.map((suggestion, index) => ( +
onSelect(suggestion)} + onMouseEnter={() => { + // Optional: update selected index on hover + }} + > + {suggestion} +
+ ))} +
+
Tab/Enter to complete • ↑↓ to navigate • Esc to close
diff --git a/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx b/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx index 5e611e8a..feeda035 100644 --- a/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx +++ b/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx @@ -34,6 +34,8 @@ import { Search, Loader2, Terminal, + LayoutGrid, + MonitorCheck, } from "lucide-react"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; @@ -67,7 +69,7 @@ interface TabData { [key: string]: unknown; } -interface SSHUtilitySidebarProps { +interface SSHToolsSidebarProps { isOpen: boolean; onClose: () => void; onSnippetExecute: (content: string) => void; @@ -85,12 +87,21 @@ export function SSHToolsSidebar({ setSidebarWidth, initialTab, onTabChange, -}: SSHUtilitySidebarProps) { +}: SSHToolsSidebarProps) { const { t } = useTranslation(); const { confirmWithToast } = useConfirmation(); - const { tabs, currentTab } = useTabs() as { + const { + tabs, + currentTab, + allSplitScreenTab, + setSplitScreenTab, + setCurrentTab, + } = useTabs() as { tabs: TabData[]; currentTab: number | null; + allSplitScreenTab: number[]; + setSplitScreenTab: (tabId: number) => void; + setCurrentTab: (tabId: number) => void; }; const [activeTab, setActiveTab] = useState(initialTab || "ssh-tools"); @@ -141,6 +152,17 @@ export function SSHToolsSidebar({ const [historyRefreshCounter, setHistoryRefreshCounter] = useState(0); const commandHistoryScrollRef = React.useRef(null); + // Split Screen state + const [splitMode, setSplitMode] = useState<"none" | "2" | "3" | "4">("none"); + const [splitAssignments, setSplitAssignments] = useState>( + new Map(), + ); + const [previewKey, setPreviewKey] = useState(0); + const [draggedTabId, setDraggedTabId] = useState(null); + const [dragOverCellIndex, setDragOverCellIndex] = useState( + null, + ); + // Resize state const [isResizing, setIsResizing] = useState(false); const startXRef = React.useRef(null); @@ -152,6 +174,15 @@ export function SSHToolsSidebar({ activeUiTab?.type === "terminal" ? activeUiTab : undefined; const activeTerminalHostId = activeTerminal?.hostConfig?.id; + // Get splittable tabs (terminal, server, file_manager) + const splittableTabs = tabs.filter( + (tab: TabData) => + tab.type === "terminal" || + tab.type === "server" || + tab.type === "file_manager" || + tab.type === "user_profile", + ); + // Fetch command history useEffect(() => { if (isOpen && activeTab === "command-history") { @@ -567,6 +598,148 @@ export function SSHToolsSidebar({ toast.success(t("snippets.copySuccess", { name: snippet.name })); }; + // Split Screen handlers + const handleSplitModeChange = (mode: "none" | "2" | "3" | "4") => { + setSplitMode(mode); + + if (mode === "none") { + // Clear all splits + handleClearSplit(); + } else { + // Clear assignments when changing modes + setSplitAssignments(new Map()); + setPreviewKey((prev) => prev + 1); + } + }; + + const handleDragStart = (tabId: number) => { + setDraggedTabId(tabId); + }; + + const handleDragEnd = () => { + setDraggedTabId(null); + setDragOverCellIndex(null); + }; + + const handleDragOver = (e: React.DragEvent, cellIndex: number) => { + e.preventDefault(); + setDragOverCellIndex(cellIndex); + }; + + const handleDragLeave = () => { + setDragOverCellIndex(null); + }; + + const handleDrop = (cellIndex: number) => { + if (draggedTabId === null) return; + + setSplitAssignments((prev) => { + const newMap = new Map(prev); + // Remove this tab from any other cell + Array.from(newMap.entries()).forEach(([idx, id]) => { + if (id === draggedTabId && idx !== cellIndex) { + newMap.delete(idx); + } + }); + newMap.set(cellIndex, draggedTabId); + return newMap; + }); + + setDraggedTabId(null); + setDragOverCellIndex(null); + setPreviewKey((prev) => prev + 1); + }; + + const handleRemoveFromCell = (cellIndex: number) => { + setSplitAssignments((prev) => { + const newMap = new Map(prev); + newMap.delete(cellIndex); + setPreviewKey((prev) => prev + 1); + return newMap; + }); + }; + + const handleApplySplit = () => { + if (splitMode === "none") { + handleClearSplit(); + return; + } + + if (splitAssignments.size === 0) { + toast.error( + t("splitScreen.error.noAssignments", { + defaultValue: "Please drag tabs to cells before applying", + }), + ); + return; + } + + const requiredSlots = parseInt(splitMode); + + // Validate: All layout spots must be filled + if (splitAssignments.size < requiredSlots) { + toast.error( + t("splitScreen.error.fillAllSlots", { + defaultValue: `Please fill all ${requiredSlots} layout spots before applying`, + count: requiredSlots, + }), + ); + return; + } + + // Build ordered array of tab IDs based on cell index (0, 1, 2, 3) + const orderedTabIds: number[] = []; + for (let i = 0; i < requiredSlots; i++) { + const tabId = splitAssignments.get(i); + if (tabId !== undefined) { + orderedTabIds.push(tabId); + } + } + + // First, clear ALL existing splits + const currentSplits = [...allSplitScreenTab]; + currentSplits.forEach((tabId) => { + setSplitScreenTab(tabId); // Toggle off + }); + + // Then, add only the newly assigned tabs to split IN ORDER + orderedTabIds.forEach((tabId) => { + setSplitScreenTab(tabId); // Toggle on + }); + + // Set first assigned tab as active if current tab is not in split + if (!orderedTabIds.includes(currentTab ?? 0)) { + setCurrentTab(orderedTabIds[0]); + } + + toast.success( + t("splitScreen.success", { + defaultValue: "Split screen applied", + }), + ); + }; + + const handleClearSplit = () => { + // Remove all tabs from split screen + allSplitScreenTab.forEach((tabId) => { + setSplitScreenTab(tabId); + }); + + setSplitMode("none"); + setSplitAssignments(new Map()); + setPreviewKey((prev) => prev + 1); + + toast.success( + t("splitScreen.cleared", { + defaultValue: "Split screen cleared", + }), + ); + }; + + const handleResetToSingle = () => { + handleClearSplit(); + }; + // Command History handlers const handleCommandSelect = (command: string) => { if (activeTerminal?.terminalRef?.current?.sendInput) { @@ -616,7 +789,7 @@ export function SSHToolsSidebar({
)}
+

+ {t("commandHistory.tabHint", { + defaultValue: + "Use Tab in Terminal to autocomplete from command history", + })} +

@@ -1040,6 +1225,219 @@ export function SSHToolsSidebar({ )}
+ + +
+ {/* Split Mode Tabs */} + + handleSplitModeChange( + value as "none" | "2" | "3" | "4", + ) + } + className="w-full" + > + + + {t("splitScreen.none", { defaultValue: "None" })} + + + {t("splitScreen.twoSplit", { + defaultValue: "2-Split", + })} + + + {t("splitScreen.threeSplit", { + defaultValue: "3-Split", + })} + + + {t("splitScreen.fourSplit", { + defaultValue: "4-Split", + })} + + + + + {/* Drag-and-Drop Interface */} + {splitMode !== "none" && ( + <> + + + {/* Available Tabs List */} +
+ +

+ {t("splitScreen.dragTabsHint", { + defaultValue: + "Drag tabs into the grid below to position them", + })} +

+
+ {splittableTabs.map((tab) => { + const isAssigned = Array.from( + splitAssignments.values(), + ).includes(tab.id); + const isDragging = draggedTabId === tab.id; + + return ( +
handleDragStart(tab.id)} + onDragEnd={handleDragEnd} + className={` + px-3 py-2 rounded-md text-sm cursor-move transition-all + ${ + isAssigned + ? "bg-dark-bg/50 text-muted-foreground cursor-not-allowed opacity-50" + : "bg-dark-bg border border-dark-border hover:border-gray-400 hover:bg-dark-bg-input" + } + ${isDragging ? "opacity-50" : ""} + `} + > + + {tab.title} + +
+ ); + })} +
+
+ + + + {/* Drop Grid */} +
+ +
+ {Array.from( + { length: parseInt(splitMode) }, + (_, idx) => { + const assignedTabId = + splitAssignments.get(idx); + const assignedTab = assignedTabId + ? tabs.find((t) => t.id === assignedTabId) + : null; + const isHovered = dragOverCellIndex === idx; + const isEmpty = !assignedTabId; + + return ( +
handleDragOver(e, idx)} + onDragLeave={handleDragLeave} + onDrop={() => handleDrop(idx)} + className={` + relative bg-dark-bg border-2 rounded-md p-3 min-h-[100px] + flex flex-col items-center justify-center transition-all + ${splitMode === "3" && idx === 2 ? "col-span-2" : ""} + ${ + isEmpty + ? "border-dashed border-dark-border" + : "border-solid border-gray-400 bg-gray-500/10" + } + ${ + isHovered && draggedTabId + ? "border-gray-500 bg-gray-500/20 ring-2 ring-gray-500/50" + : "" + } + `} + > + {assignedTab ? ( + <> + + {assignedTab.title} + + + + ) : ( + + {t("splitScreen.dropHere", { + defaultValue: "Drop tab here", + })} + + )} +
+ ); + }, + )} +
+
+ + {/* Action Buttons */} +
+ + +
+ + )} + + {/* Help Text for None mode */} + {splitMode === "none" && ( +
+ +

+ {t("splitScreen.selectMode", { + defaultValue: + "Select a split mode to get started", + })} +

+

+ {t("splitScreen.helpText", { + defaultValue: + "Choose how many tabs you want to display at once", + })} +

+
+ )} +
+
{isOpen && ( diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index f2786c72..4fd90dd4 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -555,7 +555,11 @@ export function Auth({ const error = urlParams.get("error"); if (error) { - toast.error(`${t("errors.oidcAuthFailed")}: ${error}`); + if (error === "registration_disabled") { + toast.error(t("messages.registrationDisabled")); + } else { + toast.error(`${t("errors.oidcAuthFailed")}: ${error}`); + } setOidcLoading(false); window.history.replaceState({}, document.title, window.location.pathname); return; diff --git a/src/ui/desktop/navigation/AppView.tsx b/src/ui/desktop/navigation/AppView.tsx index 1aa26cd6..e9bf4836 100644 --- a/src/ui/desktop/navigation/AppView.tsx +++ b/src/ui/desktop/navigation/AppView.tsx @@ -42,7 +42,7 @@ interface TerminalViewProps { export function AppView({ isTopbarOpen = true, rightSidebarOpen = false, - rightSidebarWidth = 300, + rightSidebarWidth = 400, }: TerminalViewProps): React.ReactElement { const { tabs, currentTab, allSplitScreenTab, removeTab } = useTabs() as { tabs: TabData[]; @@ -204,16 +204,12 @@ export function AppView({ const renderTerminalsLayer = () => { const styles: Record = {}; - const splitTabs = terminalTabs.filter((tab: TabData) => - allSplitScreenTab.includes(tab.id), - ); + // Use allSplitScreenTab order directly - it maintains the order tabs were added + const layoutTabs = allSplitScreenTab + .map((tabId) => terminalTabs.find((tab: TabData) => tab.id === tabId)) + .filter((t): t is TabData => t !== null && t !== undefined); + const mainTab = terminalTabs.find((tab: TabData) => tab.id === currentTab); - const layoutTabs = [ - mainTab, - ...splitTabs.filter( - (t: TabData) => t && t.id !== (mainTab && (mainTab as TabData).id), - ), - ].filter((t): t is TabData => t !== null && t !== undefined); if (allSplitScreenTab.length === 0 && mainTab) { const isFileManagerTab = mainTab.type === "file_manager"; @@ -358,16 +354,10 @@ export function AppView({ }; const renderSplitOverlays = () => { - const splitTabs = terminalTabs.filter((tab: TabData) => - allSplitScreenTab.includes(tab.id), - ); - const mainTab = terminalTabs.find((tab: TabData) => tab.id === currentTab); - const layoutTabs = [ - mainTab, - ...splitTabs.filter( - (t: TabData) => t && t.id !== (mainTab && (mainTab as TabData).id), - ), - ].filter((t): t is TabData => t !== null && t !== undefined); + // Use allSplitScreenTab order directly - it maintains the order tabs were added + const layoutTabs = allSplitScreenTab + .map((tabId) => terminalTabs.find((tab: TabData) => tab.id === tabId)) + .filter((t): t is TabData => t !== null && t !== undefined); if (allSplitScreenTab.length === 0) return null; const handleStyle = { diff --git a/src/ui/desktop/navigation/TopNavbar.tsx b/src/ui/desktop/navigation/TopNavbar.tsx index 78b0d0df..69f45d04 100644 --- a/src/ui/desktop/navigation/TopNavbar.tsx +++ b/src/ui/desktop/navigation/TopNavbar.tsx @@ -60,9 +60,10 @@ export function TopNavbar({ const [toolsSidebarOpen, setToolsSidebarOpen] = useState(false); const [commandHistoryTabActive, setCommandHistoryTabActive] = useState(false); + const [splitScreenTabActive, setSplitScreenTabActive] = useState(false); const [rightSidebarWidth, setRightSidebarWidth] = useState(() => { const saved = localStorage.getItem("rightSidebarWidth"); - const defaultWidth = 350; + const defaultWidth = 400; const savedWidth = saved !== null ? parseInt(saved, 10) : defaultWidth; const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2)); const maxWidth = Math.floor(window.innerWidth * 0.3); @@ -132,7 +133,11 @@ export function TopNavbar({ }; const handleTabSplit = (tabId: number) => { - setSplitScreenTab(tabId); + // Open the sidebar to the split-screen tab + setToolsSidebarOpen(true); + setCommandHistoryTabActive(false); + setSplitScreenTabActive(true); + // Optional: could pass tabId to pre-select this tab in the sidebar }; const handleTabClose = (tabId: number) => { @@ -371,17 +376,7 @@ export function TopNavbar({ const isAdmin = tab.type === "admin"; const isUserProfile = tab.type === "user_profile"; const isSplittable = isTerminal || isServer || isFileManager; - const isSplitButtonDisabled = - (isActive && !isSplitScreenActive) || - ((allSplitScreenTab?.length || 0) >= 3 && !isSplit); - const disableSplit = - !isSplittable || - isSplitButtonDisabled || - isActive || - currentTabIsHome || - currentTabIsSshManager || - currentTabIsAdmin || - currentTabIsUserProfile; + const disableSplit = !isSplittable; const disableActivate = isSplit || ((tab.type === "home" || @@ -390,7 +385,7 @@ export function TopNavbar({ tab.type === "user_profile") && isSplitScreenActive); const isHome = tab.type === "home"; - const disableClose = (isSplitScreenActive && isActive) || isHome; + const disableClose = isHome; const isDraggingThisTab = dragState.draggedIndex === index; const isTheDraggedTab = tab.id === dragState.draggedId; @@ -566,12 +561,17 @@ export function TopNavbar({ onSnippetExecute={handleSnippetExecute} sidebarWidth={rightSidebarWidth} setSidebarWidth={setRightSidebarWidth} - commandHistory={commandHistory.commandHistory} - onSelectCommand={commandHistory.onSelectCommand} - onDeleteCommand={commandHistory.onDeleteCommand} - isHistoryLoading={commandHistory.isLoading} - initialTab={commandHistoryTabActive ? "command-history" : undefined} - onTabChange={() => setCommandHistoryTabActive(false)} + initialTab={ + commandHistoryTabActive + ? "command-history" + : splitScreenTabActive + ? "split-screen" + : undefined + } + onTabChange={() => { + setCommandHistoryTabActive(false); + setSplitScreenTabActive(false); + }} />
); diff --git a/src/ui/desktop/navigation/tabs/Tab.tsx b/src/ui/desktop/navigation/tabs/Tab.tsx index ba35e918..18dc75e0 100644 --- a/src/ui/desktop/navigation/tabs/Tab.tsx +++ b/src/ui/desktop/navigation/tabs/Tab.tsx @@ -143,7 +143,7 @@ export function Tab({ onClick={!disableActivate ? onActivate : undefined} style={{ marginBottom: "-2px", - borderBottom: isActive ? "2px solid white" : "none", + borderBottom: isActive || isSplit ? "2px solid white" : "none", }} >
@@ -175,7 +175,10 @@ export function Tab({ } > )} diff --git a/src/ui/desktop/navigation/tabs/TabContext.tsx b/src/ui/desktop/navigation/tabs/TabContext.tsx index e983064c..17358d4b 100644 --- a/src/ui/desktop/navigation/tabs/TabContext.tsx +++ b/src/ui/desktop/navigation/tabs/TabContext.tsx @@ -156,11 +156,32 @@ export function TabProvider({ children }: TabProviderProps) { } setTabs((prev) => prev.filter((tab) => tab.id !== tabId)); - setAllSplitScreenTab((prev) => prev.filter((id) => id !== tabId)); + + // Remove from split screen + setAllSplitScreenTab((prev) => { + const newSplits = prev.filter((id) => id !== tabId); + // Auto-clear split mode if only 1 or fewer tabs remain in split + if (newSplits.length <= 1) { + return []; + } + return newSplits; + }); if (currentTab === tabId) { const remainingTabs = tabs.filter((tab) => tab.id !== tabId); - setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : 1); + if (remainingTabs.length > 0) { + // Try to set current tab to another split tab first, if any remain + const remainingSplitTabs = allSplitScreenTab.filter( + (id) => id !== tabId, + ); + if (remainingSplitTabs.length > 0) { + setCurrentTab(remainingSplitTabs[0]); + } else { + setCurrentTab(remainingTabs[0].id); + } + } else { + setCurrentTab(1); // Home tab + } } }; @@ -168,7 +189,7 @@ export function TabProvider({ children }: TabProviderProps) { setAllSplitScreenTab((prev) => { if (prev.includes(tabId)) { return prev.filter((id) => id !== tabId); - } else if (prev.length < 3) { + } else if (prev.length < 4) { return [...prev, tabId]; } return prev; diff --git a/src/ui/desktop/user/UserProfile.tsx b/src/ui/desktop/user/UserProfile.tsx index 963a71aa..1c8b6620 100644 --- a/src/ui/desktop/user/UserProfile.tsx +++ b/src/ui/desktop/user/UserProfile.tsx @@ -74,7 +74,7 @@ async function handleLogout() { export function UserProfile({ isTopbarOpen = true, rightSidebarOpen = false, - rightSidebarWidth = 300, + rightSidebarWidth = 400, }: UserProfileProps) { const { t } = useTranslation(); const { state: sidebarState } = useSidebar(); @@ -97,6 +97,9 @@ export function UserProfile({ const [fileColorCoding, setFileColorCoding] = useState( localStorage.getItem("fileColorCoding") !== "false", ); + const [commandAutocomplete, setCommandAutocomplete] = useState( + localStorage.getItem("commandAutocomplete") !== "false", + ); useEffect(() => { fetchUserInfo(); @@ -145,6 +148,11 @@ export function UserProfile({ window.dispatchEvent(new Event("fileColorCodingChanged")); }; + const handleCommandAutocompleteToggle = (enabled: boolean) => { + setCommandAutocomplete(enabled); + localStorage.setItem("commandAutocomplete", enabled.toString()); + }; + const handleDeleteAccount = async (e: React.FormEvent) => { e.preventDefault(); setDeleteLoading(true); @@ -363,6 +371,23 @@ export function UserProfile({
+
+
+
+ +

+ {t("profile.commandAutocompleteDesc")} +

+
+ +
+
+
diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 4113b2df..4118e7b3 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -77,6 +77,7 @@ interface UserInfo { is_admin: boolean; is_oidc: boolean; data_unlocked: boolean; + password_hash?: string; } interface UserCount { @@ -396,10 +397,12 @@ function createApiInstance( errorMessage === "Authentication required"; if (isSessionExpired || isSessionNotFound) { + // Clear token from localStorage + localStorage.removeItem("jwt"); + + // Clear Electron settings cache if (isElectron()) { - localStorage.removeItem("jwt"); - } else { - localStorage.removeItem("jwt"); + electronSettingsCache.delete("jwt"); } if (typeof window !== "undefined") { @@ -420,6 +423,12 @@ function createApiInstance( "Authentication error - token may be invalid", errorMessage, ); + + // Clear invalid token + localStorage.removeItem("jwt"); + if (isElectron()) { + electronSettingsCache.delete("jwt"); + } } } @@ -873,6 +882,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise { defaultPath: hostData.defaultPath || "/", tunnelConnections: hostData.tunnelConnections || [], jumpHosts: hostData.jumpHosts || [], + quickActions: hostData.quickActions || [], statsConfig: hostData.statsConfig ? typeof hostData.statsConfig === "string" ? hostData.statsConfig @@ -938,6 +948,7 @@ export async function updateSSHHost( defaultPath: hostData.defaultPath || "/", tunnelConnections: hostData.tunnelConnections || [], jumpHosts: hostData.jumpHosts || [], + quickActions: hostData.quickActions || [], statsConfig: hostData.statsConfig ? typeof hostData.statsConfig === "string" ? hostData.statsConfig @@ -2874,6 +2885,21 @@ export async function deleteSnippet( } } +export async function executeSnippet( + snippetId: number, + hostId: number, +): Promise<{ success: boolean; output: string; error?: string }> { + try { + const response = await authApi.post("/snippets/execute", { + snippetId, + hostId, + }); + return response.data; + } catch (error) { + throw handleApiError(error, "execute snippet"); + } +} + // ============================================================================ // HOMEPAGE API // ============================================================================ @@ -2966,10 +2992,17 @@ export async function saveCommandToHistory( /** * Get command history for a specific host * Returns array of unique commands ordered by most recent + * @param hostId - The host ID to fetch history for + * @param limit - Maximum number of commands to return (default: 100) */ -export async function getCommandHistory(hostId: number): Promise { +export async function getCommandHistory( + hostId: number, + limit: number = 100, +): Promise { try { - const response = await authApi.get(`/terminal/command_history/${hostId}`); + const response = await authApi.get(`/terminal/command_history/${hostId}`, { + params: { limit }, + }); return response.data; } catch (error) { throw handleApiError(error, "fetch command history"); @@ -3011,25 +3044,23 @@ export async function clearCommandHistory( } // ============================================================================ -// OIDC TO PASSWORD CONVERSION +// OIDC ACCOUNT LINKING // ============================================================================ /** - * Convert an OIDC user to a password-based user + * Link an OIDC user to an existing password account (merges OIDC into password account) */ -export async function convertOIDCToPassword( - targetUserId: string, - newPassword: string, - totpCode?: string, +export async function linkOIDCToPasswordAccount( + oidcUserId: string, + targetUsername: string, ): Promise<{ success: boolean; message: string }> { try { - const response = await authApi.post("/users/convert-oidc-to-password", { - targetUserId, - newPassword, - totpCode, + const response = await authApi.post("/users/link-oidc-to-password", { + oidcUserId, + targetUsername, }); return response.data; } catch (error) { - throw handleApiError(error, "convert OIDC user to password"); + throw handleApiError(error, "link OIDC account to password account"); } } diff --git a/src/ui/mobile/authentication/Auth.tsx b/src/ui/mobile/authentication/Auth.tsx index 07bd4134..a9f62fda 100644 --- a/src/ui/mobile/authentication/Auth.tsx +++ b/src/ui/mobile/authentication/Auth.tsx @@ -495,7 +495,12 @@ export function Auth({ const error = urlParams.get("error"); if (error) { - const errorMessage = `${t("errors.oidcAuthFailed")}: ${error}`; + let errorMessage: string; + if (error === "registration_disabled") { + errorMessage = t("messages.registrationDisabled"); + } else { + errorMessage = `${t("errors.oidcAuthFailed")}: ${error}`; + } setError(errorMessage); setOidcLoading(false); window.history.replaceState({}, document.title, window.location.pathname);