diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index dc42e76d..9a2db205 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,4 +1,3 @@ -services: services: termix: image: ghcr.io/lukegus/termix:latest diff --git a/electron/main.cjs b/electron/main.cjs index ce8602de..aceef2c9 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -563,12 +563,21 @@ async function clearElectronJwtCookiesAtStartup() { function getBackendPaths() { if (isDev) { const backendDir = path.join(appRoot, "dist", "backend", "backend"); - return { entryPath: path.join(backendDir, "starter.js"), backendCwd: backendDir }; + return { + entryPath: path.join(backendDir, "starter.js"), + backendCwd: backendDir, + }; } // fork() does not go through Electron's asar redirector — use the unpacked path - const unpackedRoot = appRoot.replace(/app\.asar(?!\.unpacked)/, "app.asar.unpacked"); + const unpackedRoot = appRoot.replace( + /app\.asar(?!\.unpacked)/, + "app.asar.unpacked", + ); const backendDir = path.join(unpackedRoot, "dist", "backend", "backend"); - return { entryPath: path.join(backendDir, "starter.js"), backendCwd: backendDir }; + return { + entryPath: path.join(backendDir, "starter.js"), + backendCwd: backendDir, + }; } function getBackendDataDir() { @@ -606,9 +615,10 @@ function startBackendServer() { logToFile(" backendCwd exists:", fs.existsSync(backendCwd)); backendProcess = fork(entryPath, [], { - cwd: fs.existsSync(backendCwd) && fs.statSync(backendCwd).isDirectory() - ? backendCwd - : dataDir, + cwd: + fs.existsSync(backendCwd) && fs.statSync(backendCwd).isDirectory() + ? backendCwd + : dataDir, env: { ...process.env, DATA_DIR: dataDir, @@ -1089,40 +1099,51 @@ ipcMain.handle("get-embedded-server-status", () => { }); // OIDC System Browser Authentication (RFC 8252) -ipcMain.handle("oidc-system-browser-auth", async (_event, authUrl, callbackPort) => { - const http = require("http"); +ipcMain.handle( + "oidc-system-browser-auth", + async (_event, authUrl, callbackPort) => { + const http = require("http"); - return new Promise((resolve, reject) => { - const server = http.createServer((req, res) => { - const url = new URL(req.url, `http://localhost:${callbackPort}`); - if (url.pathname === "/oidc-callback") { - const success = url.searchParams.get("success"); - const error = url.searchParams.get("error"); - const token = url.searchParams.get("token"); + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://localhost:${callbackPort}`); + if (url.pathname === "/oidc-callback") { + const success = url.searchParams.get("success"); + const error = url.searchParams.get("error"); + const token = url.searchParams.get("token"); - res.writeHead(200, { "Content-Type": "text/html" }); - res.end(`

${success === "true" ? "Authentication successful!" : "Authentication failed."}

You can close this tab and return to Termix.

`); + res.writeHead(200, { "Content-Type": "text/html" }); + res.end( + `

${success === "true" ? "Authentication successful!" : "Authentication failed."}

You can close this tab and return to Termix.

`, + ); - server.close(); - if (success === "true") { - resolve({ success: true, token }); - } else { - resolve({ success: false, error: error || "Authentication failed" }); + server.close(); + if (success === "true") { + resolve({ success: true, token }); + } else { + resolve({ + success: false, + error: error || "Authentication failed", + }); + } } - } - }); + }); - server.listen(callbackPort, "127.0.0.1", () => { - shell.openExternal(authUrl); - }); + server.listen(callbackPort, "127.0.0.1", () => { + shell.openExternal(authUrl); + }); - // Timeout after 5 minutes - setTimeout(() => { - server.close(); - reject(new Error("OIDC authentication timed out")); - }, 5 * 60 * 1000); - }); -}); + // Timeout after 5 minutes + setTimeout( + () => { + server.close(); + reject(new Error("OIDC authentication timed out")); + }, + 5 * 60 * 1000, + ); + }); + }, +); ipcMain.handle("get-server-config", () => { try { diff --git a/index.html b/index.html index f774a2b1..94725365 100644 --- a/index.html +++ b/index.html @@ -47,7 +47,9 @@ - +
diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index d3fb4642..5c14cf72 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -1166,11 +1166,28 @@ router.get("/oidc/callback", async (req, res) => { } } - const oidcAllowRegistration = - (process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() === - "true"; + let oidcAutoProvision = false; + try { + const oidcProvRow = db.$client + .prepare( + "SELECT value FROM settings WHERE key = 'oidc_auto_provision'", + ) + .get(); + if (oidcProvRow) { + oidcAutoProvision = + (oidcProvRow as Record).value === "true"; + } + } catch { + // fall through to env var check + } - if (!isFirstUser && !oidcAllowRegistration) { + if (!oidcAutoProvision) { + oidcAutoProvision = + (process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() === + "true"; + } + + if (!isFirstUser && !oidcAutoProvision) { try { const regRow = db.$client .prepare( @@ -1924,6 +1941,52 @@ router.patch("/registration-allowed", authenticateJWT, async (req, res) => { } }); +router.get("/oidc-auto-provision", async (_req, res) => { + try { + const row = db.$client + .prepare("SELECT value FROM settings WHERE key = 'oidc_auto_provision'") + .get(); + res.json({ + enabled: row + ? (row as Record).value === "true" + : false, + }); + } catch (err) { + authLogger.error("Failed to get OIDC auto-provision setting", err); + res.status(500).json({ error: "Failed to get OIDC auto-provision setting" }); + } +}); + +router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0 || !user[0].isAdmin) { + return res.status(403).json({ error: "Not authorized" }); + } + const { enabled } = req.body; + if (typeof enabled !== "boolean") { + return res.status(400).json({ error: "Invalid value for enabled" }); + } + const existing = db.$client + .prepare("SELECT value FROM settings WHERE key = 'oidc_auto_provision'") + .get(); + if (existing) { + db.$client + .prepare("UPDATE settings SET value = ? WHERE key = 'oidc_auto_provision'") + .run(enabled ? "true" : "false"); + } else { + db.$client + .prepare("INSERT INTO settings (key, value) VALUES ('oidc_auto_provision', ?)") + .run(enabled ? "true" : "false"); + } + res.json({ enabled }); + } catch (err) { + authLogger.error("Failed to set OIDC auto-provision", err); + res.status(500).json({ error: "Failed to set OIDC auto-provision" }); + } +}); + /** * @openapi * /users/password-login-allowed: diff --git a/src/backend/ssh/docker.ts b/src/backend/ssh/docker.ts index d8b6fc16..201ab6e1 100644 --- a/src/backend/ssh/docker.ts +++ b/src/backend/ssh/docker.ts @@ -139,10 +139,7 @@ async function resolveJumpHost( ): Promise { try { const hostResults = await SimpleDBOps.select( - getDb() - .select() - .from(hosts) - .where(eq(hosts.id, hostId)), + getDb().select().from(hosts).where(eq(hosts.id, hostId)), "ssh_data", userId, ); @@ -160,8 +157,10 @@ async function resolveJumpHost( const { SharedCredentialManager } = await import("../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); - const sharedCred = - await sharedCredManager.getSharedCredentialForUser(hostId, userId); + const sharedCred = await sharedCredManager.getSharedCredentialForUser( + hostId, + userId, + ); if (sharedCred) { return { ...host, diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 93e02331..f95c45b0 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -454,21 +454,33 @@ function execWithSudo( command: string, sudoPassword: string, ): Promise<{ stdout: string; stderr: string; code: number }> { + return execWithSudoBuffer(session, command, sudoPassword).then((result) => ({ + stdout: result.stdout.toString("utf8"), + stderr: result.stderr, + code: result.code, + })); +} + +function execWithSudoBuffer( + session: SSHSession, + command: string, + sudoPassword: string, +): Promise<{ stdout: Buffer; stderr: string; code: number }> { return new Promise((resolve) => { const escapedPassword = sudoPassword.replace(/'/g, "'\"'\"'"); const sudoCommand = `echo '${escapedPassword}' | sudo -S ${command} 2>&1`; execChannel(session, sudoCommand, (err, stream) => { if (err) { - resolve({ stdout: "", stderr: err.message, code: 1 }); + resolve({ stdout: Buffer.alloc(0), stderr: err.message, code: 1 }); return; } - let stdout = ""; + const stdoutChunks: Buffer[] = []; let stderr = ""; stream.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); + stdoutChunks.push(chunk); }); stream.stderr.on("data", (chunk: Buffer) => { @@ -476,12 +488,22 @@ function execWithSudo( }); stream.on("close", (code: number) => { - stdout = stdout.replace(/\[sudo\] password for .+?:\s*/g, ""); + let stdout = Buffer.concat(stdoutChunks); + const sudoPromptMatch = stdout + .toString("utf8", 0, Math.min(stdout.length, 256)) + .match(/^\[sudo\] password for .+?:\s*/); + if (sudoPromptMatch) { + stdout = stdout.subarray(Buffer.byteLength(sudoPromptMatch[0])); + } resolve({ stdout, stderr, code: code || 0 }); }); stream.on("error", (streamErr: Error) => { - resolve({ stdout, stderr: streamErr.message, code: 1 }); + resolve({ + stdout: Buffer.concat(stdoutChunks), + stderr: streamErr.message, + code: 1, + }); }); }); }); @@ -3098,22 +3120,25 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => { .includes("permission denied"); if (isPermissionDenied && sshConn.sudoPassword) { - execWithSudo( + execWithSudoBuffer( sshConn, `cat '${escapedPath}'`, sshConn.sudoPassword, ) - .then(({ stdout, stderr, code: sudoCode }) => { - if (sudoCode !== 0) { + .then((result) => { + if (result.code !== 0) { return res.status(403).json({ - error: `Permission denied: ${stderr}`, + error: `Permission denied: ${result.stderr || result.stdout.toString("utf8")}`, needsSudo: true, }); } - const sudoData = Buffer.from(stdout, "utf8"); + + const sudoData = result.stdout; const isBinary = detectBinary(sudoData); res.json({ - content: isBinary ? sudoData.toString("base64") : stdout, + content: isBinary + ? sudoData.toString("base64") + : sudoData.toString("utf8"), isBinary, size: sudoData.length, }); diff --git a/src/backend/ssh/host-resolver.ts b/src/backend/ssh/host-resolver.ts index 844da398..110969b2 100644 --- a/src/backend/ssh/host-resolver.ts +++ b/src/backend/ssh/host-resolver.ts @@ -83,13 +83,12 @@ export async function resolveHostById( .select() .from(hostAccess) .where( - and( - eq(hostAccess.hostId, hostId), - eq(hostAccess.userId, userId), - ), + and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)), ) .limit(1); - const overrideCredId = accessRecords[0]?.overrideCredentialId as number | null; + const overrideCredId = accessRecords[0]?.overrideCredentialId as + | number + | null; if (overrideCredId) { const userCreds = await SimpleDBOps.select( db @@ -113,7 +112,11 @@ export async function resolveHostById( if (!host.overrideCredentialUsername) { host.username = cred.username; } - host.authType = cred.key ? "key" : cred.password ? "password" : "none"; + host.authType = cred.key + ? "key" + : cred.password + ? "password" + : "none"; return host as unknown as SSHHost; } } diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index ef7e3538..cf400a5a 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -75,10 +75,7 @@ async function resolveJumpHost( ): Promise { try { const hostResults = await SimpleDBOps.select( - getDb() - .select() - .from(hosts) - .where(eq(hosts.id, hostId)), + getDb().select().from(hosts).where(eq(hosts.id, hostId)), "ssh_data", userId, ); @@ -96,8 +93,10 @@ async function resolveJumpHost( const { SharedCredentialManager } = await import("../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); - const sharedCred = - await sharedCredManager.getSharedCredentialForUser(hostId, userId); + const sharedCred = await sharedCredManager.getSharedCredentialForUser( + hostId, + userId, + ); if (sharedCred) { return { ...host, diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 305bb12e..50892ad9 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -1,15 +1,14 @@ import { WebSocketServer, WebSocket, type RawData } from "ws"; -import ssh2pkg from "ssh2"; -const { Client, BaseAgent, utils: ssh2Utils } = ssh2pkg; -import type { - Client as ClientType, - ClientChannel, - PseudoTtyOptions, - ParsedKey, - SignCallback, - SigningRequestOptions, - IdentityCallback, +import ssh2Pkg, { + type Client as SSHClientType, + type ClientChannel, + type PseudoTtyOptions, + type ParsedKey, + type SignCallback, + type SigningRequestOptions, + type IdentityCallback, } from "ssh2"; +const { Client, BaseAgent, utils: ssh2Utils } = ssh2Pkg; import net from "net"; import dgram from "dgram"; import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; @@ -273,13 +272,13 @@ async function createJumpHostChain( jumpHosts: Array<{ hostId: number }>, userId: string, socks5Config?: SOCKS5Config | null, -): Promise { +): Promise { if (!jumpHosts || jumpHosts.length === 0) { return null; } - let currentClient: ClientType | null = null; - const clients: ClientType[] = []; + let currentClient: SSHClientType | null = null; + const clients: SSHClientType[] = []; try { const jumpHostConfigs = await Promise.all( @@ -560,9 +559,9 @@ wss.on("connection", async (ws: WebSocket, req) => { }); let currentSessionId: string | null = null; - let sshConn: ClientType | null = null; + let sshConn: SSHClientType | null = null; let sshStream: ClientChannel | null = null; - let lastJumpClient: ClientType | null = null; + let lastJumpClient: SSHClientType | null = null; let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null; let totpPromptSent = false; let totpTimeout: NodeJS.Timeout | null = null; diff --git a/src/backend/ssh/tmux-helper.ts b/src/backend/ssh/tmux-helper.ts index 424022c7..31677ff6 100644 --- a/src/backend/ssh/tmux-helper.ts +++ b/src/backend/ssh/tmux-helper.ts @@ -124,7 +124,10 @@ export async function waitForTmuxSession( const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { - await execCommand(conn, `tmux has-session -t ${shellEscape(sessionName)} 2>/dev/null`); + await execCommand( + conn, + `tmux has-session -t ${shellEscape(sessionName)} 2>/dev/null`, + ); return sessionName; } catch { // session not ready yet diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 4569534f..7685a7dc 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -199,9 +199,8 @@ import { operation: "shutdown", }); try { - const { saveMemoryDatabaseToFile } = await import( - "./database/db/index.js" - ); + const { saveMemoryDatabaseToFile } = + await import("./database/db/index.js"); await saveMemoryDatabaseToFile(); systemLogger.info("Database saved to disk before exit", { operation: "shutdown_db_saved", diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 8e0fba68..62c2bbd6 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -272,6 +272,7 @@ export type SplitMode = | "none" | "2-way" | "3-way" + | "3-way-horizontal" | "4-way" | "5-way" | "6-way"; diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index 4ff8776f..f5486f5b 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -36,6 +36,10 @@ import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronSe import { ElectronLoginForm } from "@/auth/ElectronLoginForm"; import { Checkbox } from "@/components/checkbox"; import i18n from "@/i18n/i18n"; +import { + removeSilentSigninFromSearch, + shouldTriggerSilentSignin, +} from "./silent-signin"; const LANGUAGES = [ { code: "en", label: "English" }, @@ -234,6 +238,8 @@ export function Auth({ onLogin }: AuthProps) { const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true); const [passwordResetAllowed, setPasswordResetAllowed] = useState(true); const [oidcConfigured, setOidcConfigured] = useState(false); + const [oidcConfigLoaded, setOidcConfigLoaded] = useState(false); + const silentSigninHandledRef = useRef(false); const [firstUser, setFirstUser] = useState(false); const [dbConnectionFailed, setDbConnectionFailed] = useState(false); const [dbHealthChecking, setDbHealthChecking] = useState(true); @@ -262,7 +268,8 @@ export function Auth({ onLogin }: AuthProps) { .catch(() => setPasswordResetAllowed(false)); getOIDCConfig() .then((res) => setOidcConfigured(!!res)) - .catch(() => setOidcConfigured(false)); + .catch(() => setOidcConfigured(false)) + .finally(() => setOidcConfigLoaded(true)); }, []); useEffect(() => { @@ -643,17 +650,36 @@ export function Auth({ onLogin }: AuthProps) { } } - async function handleOIDCLogin() { + const handleOIDCLogin = useCallback(async () => { setOidcLoading(true); try { if (isElectron()) { - const electronAPI = (window as unknown as { electronAPI?: { oidcSystemBrowserAuth?: (authUrl: string, port: number) => Promise<{ success: boolean; token?: string; error?: string }> } }).electronAPI; + const electronAPI = ( + window as unknown as { + electronAPI?: { + oidcSystemBrowserAuth?: ( + authUrl: string, + port: number, + ) => Promise<{ + success: boolean; + token?: string; + error?: string; + }>; + }; + } + ).electronAPI; if (electronAPI?.oidcSystemBrowserAuth) { const callbackPort = 17832 + Math.floor(Math.random() * 100); - const authResponse = await getOIDCAuthorizeUrl(rememberMe, callbackPort); + const authResponse = await getOIDCAuthorizeUrl( + rememberMe, + callbackPort, + ); const { auth_url: authUrl } = authResponse; if (!authUrl) throw new Error(t("errors.invalidAuthUrl")); - const result = await electronAPI.oidcSystemBrowserAuth(authUrl, callbackPort); + const result = await electronAPI.oidcSystemBrowserAuth( + authUrl, + callbackPort, + ); if (result.success && result.token) { localStorage.setItem("jwt_token", result.token); window.location.reload(); @@ -679,7 +705,27 @@ export function Auth({ onLogin }: AuthProps) { ); setOidcLoading(false); } - } + }, [rememberMe, t]); + + useEffect(() => { + if (!oidcConfigLoaded || silentSigninHandledRef.current) return; + if (!shouldTriggerSilentSignin(window.location.search)) return; + + const nextSearch = removeSilentSigninFromSearch(window.location.search); + window.history.replaceState( + {}, + document.title, + `${window.location.pathname}${nextSearch}${window.location.hash}`, + ); + + silentSigninHandledRef.current = true; + if (oidcConfigured && !isElectron()) { + handleOIDCLogin(); + return; + } + + toast.info(t("errors.silentSigninOidcUnavailable")); + }, [handleOIDCLogin, oidcConfigLoaded, oidcConfigured, t]); // Electron server config / webview auth success screens if (isElectron() && !isInElectronWebView()) { diff --git a/src/ui/auth/LoginPage.tsx b/src/ui/auth/LoginPage.tsx index 4818e3c5..0569958d 100644 --- a/src/ui/auth/LoginPage.tsx +++ b/src/ui/auth/LoginPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { Button } from "@/components/button.tsx"; import { Input } from "@/components/input.tsx"; import { PasswordInput } from "@/components/password-input.tsx"; @@ -31,6 +31,10 @@ import { } from "@/main-axios"; import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig.tsx"; import { ElectronLoginForm } from "@/auth/ElectronLoginForm.tsx"; +import { + removeSilentSigninFromSearch, + shouldTriggerSilentSignin, +} from "./silent-signin"; function isMissingServerConfigError(error: unknown): boolean { if (!(error instanceof Error)) { @@ -123,6 +127,8 @@ export function Auth({ const [registrationAllowed, setRegistrationAllowed] = useState(true); const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true); const [oidcConfigured, setOidcConfigured] = useState(false); + const [oidcConfigLoaded, setOidcConfigLoaded] = useState(false); + const silentSigninHandledRef = useRef(false); const [resetStep, setResetStep] = useState< "initiate" | "verify" | "newPassword" @@ -248,6 +254,9 @@ export function Auth({ } else { setOidcConfigured(false); } + }) + .finally(() => { + setOidcConfigLoaded(true); }); }, []); @@ -625,7 +634,7 @@ export function Auth({ } } - async function handleOIDCLogin() { + const handleOIDCLogin = useCallback(async () => { setOidcLoading(true); try { const authResponse = await getOIDCAuthorizeUrl(rememberMe); @@ -648,7 +657,27 @@ export function Auth({ toast.error(errorMessage); setOidcLoading(false); } - } + }, [rememberMe, t]); + + useEffect(() => { + if (!oidcConfigLoaded || silentSigninHandledRef.current) return; + if (!shouldTriggerSilentSignin(window.location.search)) return; + + const nextSearch = removeSilentSigninFromSearch(window.location.search); + window.history.replaceState( + {}, + document.title, + `${window.location.pathname}${nextSearch}${window.location.hash}`, + ); + + silentSigninHandledRef.current = true; + if (oidcConfigured && !isElectron()) { + handleOIDCLogin(); + return; + } + + toast.info(t("errors.silentSigninOidcUnavailable")); + }, [handleOIDCLogin, oidcConfigLoaded, oidcConfigured, t]); useEffect(() => { const urlParams = new URLSearchParams(window.location.search); diff --git a/src/ui/auth/silent-signin.ts b/src/ui/auth/silent-signin.ts new file mode 100644 index 00000000..60a1e754 --- /dev/null +++ b/src/ui/auth/silent-signin.ts @@ -0,0 +1,18 @@ +export function shouldTriggerSilentSignin(search: string) { + const params = new URLSearchParams(search); + for (const [key, rawValue] of params) { + if (key.toLowerCase() !== "silentsignin") continue; + const value = rawValue; + return value === "" || value === "true" || value === "1"; + } + return false; +} + +export function removeSilentSigninFromSearch(search: string) { + const params = new URLSearchParams(search); + for (const key of Array.from(params.keys())) { + if (key.toLowerCase() === "silentsignin") params.delete(key); + } + const nextSearch = params.toString(); + return nextSearch ? `?${nextSearch}` : ""; +} diff --git a/src/ui/features/file-manager/FileManager.tsx b/src/ui/features/file-manager/FileManager.tsx index a16a1e2c..8f799b2f 100644 --- a/src/ui/features/file-manager/FileManager.tsx +++ b/src/ui/features/file-manager/FileManager.tsx @@ -2551,7 +2551,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { return (
-
+
{ + client.onfile = ( + stream: Guacamole.InputStream, + mimetype: string, + filename: string, + ) => { const reader = new Guacamole.BlobReader(stream, mimetype); reader.onend = () => { const blob = reader.getBlob(); diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index 9768721c..46d5d736 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -1981,9 +1981,10 @@ const TerminalInner = forwardRef( const clipboardAddon = new ClipboardAddon(undefined, clipboardProvider); const unicode11Addon = new Unicode11Addon(); const webLinksAddon = new WebLinksAddon((_event, uri) => { - const url = uri.startsWith("http://") || uri.startsWith("https://") - ? uri - : `https://${uri}`; + const url = + uri.startsWith("http://") || uri.startsWith("https://") + ? uri + : `https://${uri}`; window.open(url, "_blank"); }); @@ -2148,7 +2149,8 @@ const TerminalInner = forwardRef( } const persistenceEnabled = - localStorage.getItem("enableTerminalSessionPersistence") !== "false"; + localStorage.getItem("enableTerminalSessionPersistence") !== + "false"; if ( !persistenceEnabled && sessionIdRef.current && diff --git a/src/ui/lib/theme.ts b/src/ui/lib/theme.ts index 1d4200fc..70edf91d 100644 --- a/src/ui/lib/theme.ts +++ b/src/ui/lib/theme.ts @@ -98,7 +98,8 @@ export const FOLDER_COLORS = [ export const SPLIT_MODES: { id: SplitMode; label: string }[] = [ { id: "none", label: "None" }, { id: "2-way", label: "2-Way" }, - { id: "3-way", label: "3-Way" }, + { id: "3-way", label: "3-Way (V)" }, + { id: "3-way-horizontal", label: "3-Way (H)" }, { id: "4-way", label: "4-Way" }, { id: "5-way", label: "5-Way" }, { id: "6-way", label: "6-Way" }, @@ -108,6 +109,7 @@ export const PANE_COUNTS: Record = { none: 0, "2-way": 2, "3-way": 3, + "3-way-horizontal": 3, "4-way": 4, "5-way": 5, "6-way": 6, @@ -117,6 +119,7 @@ export const PANE_LAYOUTS: Record = { none: "", "2-way": "grid-cols-2 grid-rows-1", "3-way": "grid-cols-2 grid-rows-2", + "3-way-horizontal": "grid-cols-2 grid-rows-2", "4-way": "grid-cols-2 grid-rows-2", "5-way": "grid-cols-3 grid-rows-2", "6-way": "grid-cols-3 grid-rows-2", diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index f990f4b0..5303693a 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1228,6 +1228,7 @@ "failedCompleteReset": "Failed to complete password reset", "invalidTotpCode": "Invalid TOTP code", "failedOidcLogin": "Failed to start OIDC login", + "silentSigninOidcUnavailable": "Silent sign-in was requested, but OIDC login is not available.", "failedUserInfo": "Failed to get user info after login", "oidcAuthFailed": "OIDC authentication failed", "invalidAuthUrl": "Invalid authorization URL received from backend", diff --git a/src/ui/locales/translated/zh_CN.json b/src/ui/locales/translated/zh_CN.json index baa5e1bd..34fddb14 100644 --- a/src/ui/locales/translated/zh_CN.json +++ b/src/ui/locales/translated/zh_CN.json @@ -2336,6 +2336,7 @@ "failedCompleteReset": "密码重置失败", "invalidTotpCode": "无效的 TOTP 代码", "failedOidcLogin": "OIDC 登录启动失败", + "silentSigninOidcUnavailable": "已请求静默登录,但当前未启用 OIDC 登录。", "failedUserInfo": "登录后无法获取用户信息", "oidcAuthFailed": "OIDC 身份验证失败", "noTokenReceived": "登录未收到令牌", diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 8529974c..c0b47ba3 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -3326,6 +3326,28 @@ export async function updateRegistrationAllowed( } } +export async function getOidcAutoProvision(): Promise<{ enabled: boolean }> { + try { + const response = await authApi.get("/users/oidc-auto-provision"); + return response.data; + } catch (error) { + handleApiError(error, "check OIDC auto-provision status"); + } +} + +export async function updateOidcAutoProvision( + enabled: boolean, +): Promise> { + try { + const response = await authApi.patch("/users/oidc-auto-provision", { + enabled, + }); + return response.data; + } catch (error) { + handleApiError(error, "update OIDC auto-provision"); + } +} + export async function updatePasswordLoginAllowed( allowed: boolean, ): Promise<{ allowed: boolean }> { diff --git a/src/ui/shell/SplitView.tsx b/src/ui/shell/SplitView.tsx index 30d614e8..03849ef3 100644 --- a/src/ui/shell/SplitView.tsx +++ b/src/ui/shell/SplitView.tsx @@ -19,6 +19,8 @@ function defaultSizes(mode: SplitMode): { return { rowSizes: [100], rowColSizes: [[50, 50]] }; case "3-way": return { rowSizes: [50, 50], rowColSizes: [[50, 50], [100]] }; + case "3-way-horizontal": + return { rowSizes: [50, 50], rowColSizes: [[50, 50], [100]] }; case "4-way": return { rowSizes: [50, 50], @@ -444,6 +446,36 @@ export function SplitView({
)} + {splitMode === "3-way-horizontal" && ( +
+
+
+ {pane(0)} +
+ onColDivider(e, 0, 0)} + onTouchStart={(e) => onColDividerTouch(e, 0, 0)} + /> +
+ {pane(1)} +
+
+ onRowDivider(e, 0)} + onTouchStart={(e) => onRowDividerTouch(e, 0)} + /> +
+ {pane(2)} +
+
+ )} + {splitMode === "4-way" && (
diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx index f17bf316..e69a40e9 100644 --- a/src/ui/sidebar/AdminSettingsPanel.tsx +++ b/src/ui/sidebar/AdminSettingsPanel.tsx @@ -31,6 +31,8 @@ import { getAdminOIDCConfig, updateOIDCConfig, disableOIDCConfig, + getOidcAutoProvision, + updateOidcAutoProvision, isElectron, getUserRoles, assignRoleToUser, @@ -134,6 +136,7 @@ export function AdminSettingsPanel() { const [logLevel, setLogLevel] = useState("info"); // OIDC state + const [oidcAutoProvision, setOidcAutoProvision] = useState(false); const [oidcClientId, setOidcClientId] = useState(""); const [oidcClientSecret, setOidcClientSecret] = useState(""); const [oidcAuthUrl, setOidcAuthUrl] = useState(""); @@ -238,7 +241,7 @@ export function AdminSettingsPanel() { async function loadGeneralSettings() { try { - const [reg, pwLogin, pwReset, timeout, monitoring, level, guac] = + const [reg, pwLogin, pwReset, timeout, monitoring, level, guac, oidcProv] = await Promise.allSettled([ getRegistrationAllowed(), getPasswordLoginAllowed(), @@ -247,11 +250,14 @@ export function AdminSettingsPanel() { getGlobalMonitoringSettings(), getLogLevel(), getGuacamoleSettings(), + getOidcAutoProvision(), ]); if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed); if (pwLogin.status === "fulfilled") setAllowPasswordLogin(pwLogin.value.allowed); + if (oidcProv.status === "fulfilled") + setOidcAutoProvision(oidcProv.value.enabled); if (pwReset.status === "fulfilled") setAllowPasswordReset(pwReset.value); if (timeout.status === "fulfilled") setSessionTimeout(String(timeout.value.timeoutHours)); @@ -321,6 +327,17 @@ export function AdminSettingsPanel() { } } + async function handleToggleOidcAutoProvision() { + const newVal = !oidcAutoProvision; + setOidcAutoProvision(newVal); + try { + await updateOidcAutoProvision(newVal); + } catch { + setOidcAutoProvision(!newVal); + toast.error("Failed to update OIDC auto-provision setting"); + } + } + async function handleTogglePasswordReset() { const newVal = !allowPasswordReset; setAllowPasswordReset(newVal); @@ -700,6 +717,15 @@ export function AdminSettingsPanel() { onToggle={handleTogglePasswordLogin} /> + + + = {
), + "3-way-horizontal": ( +
+
+
+
+
+
+
+ ), "4-way": (