Merge remote-tracking branch 'origin/dev-2.3.0' into dev-2.3.0

This commit is contained in:
LukeGus
2026-05-20 13:58:28 -05:00
25 changed files with 412 additions and 106 deletions
-1
View File
@@ -1,4 +1,3 @@
services:
services: services:
termix: termix:
image: ghcr.io/lukegus/termix:latest image: ghcr.io/lukegus/termix:latest
+31 -10
View File
@@ -563,12 +563,21 @@ async function clearElectronJwtCookiesAtStartup() {
function getBackendPaths() { function getBackendPaths() {
if (isDev) { if (isDev) {
const backendDir = path.join(appRoot, "dist", "backend", "backend"); 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 // 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"); 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() { function getBackendDataDir() {
@@ -606,7 +615,8 @@ function startBackendServer() {
logToFile(" backendCwd exists:", fs.existsSync(backendCwd)); logToFile(" backendCwd exists:", fs.existsSync(backendCwd));
backendProcess = fork(entryPath, [], { backendProcess = fork(entryPath, [], {
cwd: fs.existsSync(backendCwd) && fs.statSync(backendCwd).isDirectory() cwd:
fs.existsSync(backendCwd) && fs.statSync(backendCwd).isDirectory()
? backendCwd ? backendCwd
: dataDir, : dataDir,
env: { env: {
@@ -1089,7 +1099,9 @@ ipcMain.handle("get-embedded-server-status", () => {
}); });
// OIDC System Browser Authentication (RFC 8252) // OIDC System Browser Authentication (RFC 8252)
ipcMain.handle("oidc-system-browser-auth", async (_event, authUrl, callbackPort) => { ipcMain.handle(
"oidc-system-browser-auth",
async (_event, authUrl, callbackPort) => {
const http = require("http"); const http = require("http");
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -1101,13 +1113,18 @@ ipcMain.handle("oidc-system-browser-auth", async (_event, authUrl, callbackPort)
const token = url.searchParams.get("token"); const token = url.searchParams.get("token");
res.writeHead(200, { "Content-Type": "text/html" }); res.writeHead(200, { "Content-Type": "text/html" });
res.end(`<html><body><h2>${success === "true" ? "Authentication successful!" : "Authentication failed."}</h2><p>You can close this tab and return to Termix.</p><script>window.close()</script></body></html>`); res.end(
`<html><body><h2>${success === "true" ? "Authentication successful!" : "Authentication failed."}</h2><p>You can close this tab and return to Termix.</p><script>window.close()</script></body></html>`,
);
server.close(); server.close();
if (success === "true") { if (success === "true") {
resolve({ success: true, token }); resolve({ success: true, token });
} else { } else {
resolve({ success: false, error: error || "Authentication failed" }); resolve({
success: false,
error: error || "Authentication failed",
});
} }
} }
}); });
@@ -1117,12 +1134,16 @@ ipcMain.handle("oidc-system-browser-auth", async (_event, authUrl, callbackPort)
}); });
// Timeout after 5 minutes // Timeout after 5 minutes
setTimeout(() => { setTimeout(
() => {
server.close(); server.close();
reject(new Error("OIDC authentication timed out")); reject(new Error("OIDC authentication timed out"));
}, 5 * 60 * 1000); },
}); 5 * 60 * 1000,
);
}); });
},
);
ipcMain.handle("get-server-config", () => { ipcMain.handle("get-server-config", () => {
try { try {
+3 -1
View File
@@ -47,7 +47,9 @@
</style> </style>
</head> </head>
<body> <body>
<script>window.__TERMIX_BASE_PATH__ = "";</script> <script>
window.__TERMIX_BASE_PATH__ = "";
</script>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
+65 -2
View File
@@ -1166,11 +1166,28 @@ router.get("/oidc/callback", async (req, res) => {
} }
} }
const oidcAllowRegistration = 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<string, unknown>).value === "true";
}
} catch {
// fall through to env var check
}
if (!oidcAutoProvision) {
oidcAutoProvision =
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() === (process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() ===
"true"; "true";
}
if (!isFirstUser && !oidcAllowRegistration) { if (!isFirstUser && !oidcAutoProvision) {
try { try {
const regRow = db.$client const regRow = db.$client
.prepare( .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<string, unknown>).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 * @openapi
* /users/password-login-allowed: * /users/password-login-allowed:
+5 -6
View File
@@ -139,10 +139,7 @@ async function resolveJumpHost(
): Promise<JumpHostConfig | null> { ): Promise<JumpHostConfig | null> {
try { try {
const hostResults = await SimpleDBOps.select( const hostResults = await SimpleDBOps.select(
getDb() getDb().select().from(hosts).where(eq(hosts.id, hostId)),
.select()
.from(hosts)
.where(eq(hosts.id, hostId)),
"ssh_data", "ssh_data",
userId, userId,
); );
@@ -160,8 +157,10 @@ async function resolveJumpHost(
const { SharedCredentialManager } = const { SharedCredentialManager } =
await import("../utils/shared-credential-manager.js"); await import("../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance(); const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred = const sharedCred = await sharedCredManager.getSharedCredentialForUser(
await sharedCredManager.getSharedCredentialForUser(hostId, userId); hostId,
userId,
);
if (sharedCred) { if (sharedCred) {
return { return {
...host, ...host,
+36 -11
View File
@@ -454,21 +454,33 @@ function execWithSudo(
command: string, command: string,
sudoPassword: string, sudoPassword: string,
): Promise<{ stdout: string; stderr: string; code: number }> { ): 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) => { return new Promise((resolve) => {
const escapedPassword = sudoPassword.replace(/'/g, "'\"'\"'"); const escapedPassword = sudoPassword.replace(/'/g, "'\"'\"'");
const sudoCommand = `echo '${escapedPassword}' | sudo -S ${command} 2>&1`; const sudoCommand = `echo '${escapedPassword}' | sudo -S ${command} 2>&1`;
execChannel(session, sudoCommand, (err, stream) => { execChannel(session, sudoCommand, (err, stream) => {
if (err) { if (err) {
resolve({ stdout: "", stderr: err.message, code: 1 }); resolve({ stdout: Buffer.alloc(0), stderr: err.message, code: 1 });
return; return;
} }
let stdout = ""; const stdoutChunks: Buffer[] = [];
let stderr = ""; let stderr = "";
stream.on("data", (chunk: Buffer) => { stream.on("data", (chunk: Buffer) => {
stdout += chunk.toString(); stdoutChunks.push(chunk);
}); });
stream.stderr.on("data", (chunk: Buffer) => { stream.stderr.on("data", (chunk: Buffer) => {
@@ -476,12 +488,22 @@ function execWithSudo(
}); });
stream.on("close", (code: number) => { 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 }); resolve({ stdout, stderr, code: code || 0 });
}); });
stream.on("error", (streamErr: Error) => { 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"); .includes("permission denied");
if (isPermissionDenied && sshConn.sudoPassword) { if (isPermissionDenied && sshConn.sudoPassword) {
execWithSudo( execWithSudoBuffer(
sshConn, sshConn,
`cat '${escapedPath}'`, `cat '${escapedPath}'`,
sshConn.sudoPassword, sshConn.sudoPassword,
) )
.then(({ stdout, stderr, code: sudoCode }) => { .then((result) => {
if (sudoCode !== 0) { if (result.code !== 0) {
return res.status(403).json({ return res.status(403).json({
error: `Permission denied: ${stderr}`, error: `Permission denied: ${result.stderr || result.stdout.toString("utf8")}`,
needsSudo: true, needsSudo: true,
}); });
} }
const sudoData = Buffer.from(stdout, "utf8");
const sudoData = result.stdout;
const isBinary = detectBinary(sudoData); const isBinary = detectBinary(sudoData);
res.json({ res.json({
content: isBinary ? sudoData.toString("base64") : stdout, content: isBinary
? sudoData.toString("base64")
: sudoData.toString("utf8"),
isBinary, isBinary,
size: sudoData.length, size: sudoData.length,
}); });
+9 -6
View File
@@ -83,13 +83,12 @@ export async function resolveHostById(
.select() .select()
.from(hostAccess) .from(hostAccess)
.where( .where(
and( and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)),
eq(hostAccess.hostId, hostId),
eq(hostAccess.userId, userId),
),
) )
.limit(1); .limit(1);
const overrideCredId = accessRecords[0]?.overrideCredentialId as number | null; const overrideCredId = accessRecords[0]?.overrideCredentialId as
| number
| null;
if (overrideCredId) { if (overrideCredId) {
const userCreds = await SimpleDBOps.select( const userCreds = await SimpleDBOps.select(
db db
@@ -113,7 +112,11 @@ export async function resolveHostById(
if (!host.overrideCredentialUsername) { if (!host.overrideCredentialUsername) {
host.username = cred.username; 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; return host as unknown as SSHHost;
} }
} }
+5 -6
View File
@@ -75,10 +75,7 @@ async function resolveJumpHost(
): Promise<JumpHostConfig | null> { ): Promise<JumpHostConfig | null> {
try { try {
const hostResults = await SimpleDBOps.select( const hostResults = await SimpleDBOps.select(
getDb() getDb().select().from(hosts).where(eq(hosts.id, hostId)),
.select()
.from(hosts)
.where(eq(hosts.id, hostId)),
"ssh_data", "ssh_data",
userId, userId,
); );
@@ -96,8 +93,10 @@ async function resolveJumpHost(
const { SharedCredentialManager } = const { SharedCredentialManager } =
await import("../utils/shared-credential-manager.js"); await import("../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance(); const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred = const sharedCred = await sharedCredManager.getSharedCredentialForUser(
await sharedCredManager.getSharedCredentialForUser(hostId, userId); hostId,
userId,
);
if (sharedCred) { if (sharedCred) {
return { return {
...host, ...host,
+14 -15
View File
@@ -1,15 +1,14 @@
import { WebSocketServer, WebSocket, type RawData } from "ws"; import { WebSocketServer, WebSocket, type RawData } from "ws";
import ssh2pkg from "ssh2"; import ssh2Pkg, {
const { Client, BaseAgent, utils: ssh2Utils } = ssh2pkg; type Client as SSHClientType,
import type { type ClientChannel,
Client as ClientType, type PseudoTtyOptions,
ClientChannel, type ParsedKey,
PseudoTtyOptions, type SignCallback,
ParsedKey, type SigningRequestOptions,
SignCallback, type IdentityCallback,
SigningRequestOptions,
IdentityCallback,
} from "ssh2"; } from "ssh2";
const { Client, BaseAgent, utils: ssh2Utils } = ssh2Pkg;
import net from "net"; import net from "net";
import dgram from "dgram"; import dgram from "dgram";
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
@@ -273,13 +272,13 @@ async function createJumpHostChain(
jumpHosts: Array<{ hostId: number }>, jumpHosts: Array<{ hostId: number }>,
userId: string, userId: string,
socks5Config?: SOCKS5Config | null, socks5Config?: SOCKS5Config | null,
): Promise<ClientType | null> { ): Promise<SSHClientType | null> {
if (!jumpHosts || jumpHosts.length === 0) { if (!jumpHosts || jumpHosts.length === 0) {
return null; return null;
} }
let currentClient: ClientType | null = null; let currentClient: SSHClientType | null = null;
const clients: ClientType[] = []; const clients: SSHClientType[] = [];
try { try {
const jumpHostConfigs = await Promise.all( const jumpHostConfigs = await Promise.all(
@@ -560,9 +559,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
}); });
let currentSessionId: string | null = null; let currentSessionId: string | null = null;
let sshConn: ClientType | null = null; let sshConn: SSHClientType | null = null;
let sshStream: ClientChannel | 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 keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
let totpPromptSent = false; let totpPromptSent = false;
let totpTimeout: NodeJS.Timeout | null = null; let totpTimeout: NodeJS.Timeout | null = null;
+4 -1
View File
@@ -124,7 +124,10 @@ export async function waitForTmuxSession(
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) { while (Date.now() < deadline) {
try { 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; return sessionName;
} catch { } catch {
// session not ready yet // session not ready yet
+2 -3
View File
@@ -199,9 +199,8 @@ import {
operation: "shutdown", operation: "shutdown",
}); });
try { try {
const { saveMemoryDatabaseToFile } = await import( const { saveMemoryDatabaseToFile } =
"./database/db/index.js" await import("./database/db/index.js");
);
await saveMemoryDatabaseToFile(); await saveMemoryDatabaseToFile();
systemLogger.info("Database saved to disk before exit", { systemLogger.info("Database saved to disk before exit", {
operation: "shutdown_db_saved", operation: "shutdown_db_saved",
+1
View File
@@ -272,6 +272,7 @@ export type SplitMode =
| "none" | "none"
| "2-way" | "2-way"
| "3-way" | "3-way"
| "3-way-horizontal"
| "4-way" | "4-way"
| "5-way" | "5-way"
| "6-way"; | "6-way";
+51 -5
View File
@@ -36,6 +36,10 @@ import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronSe
import { ElectronLoginForm } from "@/auth/ElectronLoginForm"; import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
import { Checkbox } from "@/components/checkbox"; import { Checkbox } from "@/components/checkbox";
import i18n from "@/i18n/i18n"; import i18n from "@/i18n/i18n";
import {
removeSilentSigninFromSearch,
shouldTriggerSilentSignin,
} from "./silent-signin";
const LANGUAGES = [ const LANGUAGES = [
{ code: "en", label: "English" }, { code: "en", label: "English" },
@@ -234,6 +238,8 @@ export function Auth({ onLogin }: AuthProps) {
const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true); const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true);
const [passwordResetAllowed, setPasswordResetAllowed] = useState(true); const [passwordResetAllowed, setPasswordResetAllowed] = useState(true);
const [oidcConfigured, setOidcConfigured] = useState(false); const [oidcConfigured, setOidcConfigured] = useState(false);
const [oidcConfigLoaded, setOidcConfigLoaded] = useState(false);
const silentSigninHandledRef = useRef(false);
const [firstUser, setFirstUser] = useState(false); const [firstUser, setFirstUser] = useState(false);
const [dbConnectionFailed, setDbConnectionFailed] = useState(false); const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
const [dbHealthChecking, setDbHealthChecking] = useState(true); const [dbHealthChecking, setDbHealthChecking] = useState(true);
@@ -262,7 +268,8 @@ export function Auth({ onLogin }: AuthProps) {
.catch(() => setPasswordResetAllowed(false)); .catch(() => setPasswordResetAllowed(false));
getOIDCConfig() getOIDCConfig()
.then((res) => setOidcConfigured(!!res)) .then((res) => setOidcConfigured(!!res))
.catch(() => setOidcConfigured(false)); .catch(() => setOidcConfigured(false))
.finally(() => setOidcConfigLoaded(true));
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -643,17 +650,36 @@ export function Auth({ onLogin }: AuthProps) {
} }
} }
async function handleOIDCLogin() { const handleOIDCLogin = useCallback(async () => {
setOidcLoading(true); setOidcLoading(true);
try { try {
if (isElectron()) { 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) { if (electronAPI?.oidcSystemBrowserAuth) {
const callbackPort = 17832 + Math.floor(Math.random() * 100); 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; const { auth_url: authUrl } = authResponse;
if (!authUrl) throw new Error(t("errors.invalidAuthUrl")); 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) { if (result.success && result.token) {
localStorage.setItem("jwt_token", result.token); localStorage.setItem("jwt_token", result.token);
window.location.reload(); window.location.reload();
@@ -679,8 +705,28 @@ export function Auth({ onLogin }: AuthProps) {
); );
setOidcLoading(false); 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 // Electron server config / webview auth success screens
if (isElectron() && !isInElectronWebView()) { if (isElectron() && !isInElectronWebView()) {
if (showServerConfig === null) if (showServerConfig === null)
+31 -2
View File
@@ -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 { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx"; import { Input } from "@/components/input.tsx";
import { PasswordInput } from "@/components/password-input.tsx"; import { PasswordInput } from "@/components/password-input.tsx";
@@ -31,6 +31,10 @@ import {
} from "@/main-axios"; } from "@/main-axios";
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig.tsx"; import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig.tsx";
import { ElectronLoginForm } from "@/auth/ElectronLoginForm.tsx"; import { ElectronLoginForm } from "@/auth/ElectronLoginForm.tsx";
import {
removeSilentSigninFromSearch,
shouldTriggerSilentSignin,
} from "./silent-signin";
function isMissingServerConfigError(error: unknown): boolean { function isMissingServerConfigError(error: unknown): boolean {
if (!(error instanceof Error)) { if (!(error instanceof Error)) {
@@ -123,6 +127,8 @@ export function Auth({
const [registrationAllowed, setRegistrationAllowed] = useState(true); const [registrationAllowed, setRegistrationAllowed] = useState(true);
const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true); const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true);
const [oidcConfigured, setOidcConfigured] = useState(false); const [oidcConfigured, setOidcConfigured] = useState(false);
const [oidcConfigLoaded, setOidcConfigLoaded] = useState(false);
const silentSigninHandledRef = useRef(false);
const [resetStep, setResetStep] = useState< const [resetStep, setResetStep] = useState<
"initiate" | "verify" | "newPassword" "initiate" | "verify" | "newPassword"
@@ -248,6 +254,9 @@ export function Auth({
} else { } else {
setOidcConfigured(false); setOidcConfigured(false);
} }
})
.finally(() => {
setOidcConfigLoaded(true);
}); });
}, []); }, []);
@@ -625,7 +634,7 @@ export function Auth({
} }
} }
async function handleOIDCLogin() { const handleOIDCLogin = useCallback(async () => {
setOidcLoading(true); setOidcLoading(true);
try { try {
const authResponse = await getOIDCAuthorizeUrl(rememberMe); const authResponse = await getOIDCAuthorizeUrl(rememberMe);
@@ -648,8 +657,28 @@ export function Auth({
toast.error(errorMessage); toast.error(errorMessage);
setOidcLoading(false); 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(() => { useEffect(() => {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const success = urlParams.get("success"); const success = urlParams.get("success");
+18
View File
@@ -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}` : "";
}
+2 -2
View File
@@ -2551,7 +2551,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
return ( return (
<div className="h-full flex flex-col bg-background relative overflow-hidden isolate"> <div className="h-full flex flex-col bg-background relative overflow-hidden isolate">
<div <div
className="h-full w-full flex flex-col" className="h-full w-full flex flex-col min-h-0"
style={{ style={{
visibility: isConnectionLogExpanded ? "hidden" : "visible", visibility: isConnectionLogExpanded ? "hidden" : "visible",
}} }}
@@ -2855,7 +2855,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
: "hidden md:flex", : "hidden md:flex",
)} )}
> >
<div className="flex-1 flex flex-col overflow-hidden border border-border bg-card"> <div className="flex-1 flex flex-col overflow-hidden min-h-0 border border-border bg-card">
<FileManagerSidebar <FileManagerSidebar
currentHost={currentHost} currentHost={currentHost}
currentPath={currentPath} currentPath={currentPath}
@@ -395,7 +395,11 @@ export const GuacamoleDisplay = forwardRef<
Guacamole.AudioPlayer.getInstance(stream, mimetype); Guacamole.AudioPlayer.getInstance(stream, mimetype);
}; };
client.onfile = (stream: Guacamole.InputStream, mimetype: string, filename: string) => { client.onfile = (
stream: Guacamole.InputStream,
mimetype: string,
filename: string,
) => {
const reader = new Guacamole.BlobReader(stream, mimetype); const reader = new Guacamole.BlobReader(stream, mimetype);
reader.onend = () => { reader.onend = () => {
const blob = reader.getBlob(); const blob = reader.getBlob();
+4 -2
View File
@@ -1981,7 +1981,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const clipboardAddon = new ClipboardAddon(undefined, clipboardProvider); const clipboardAddon = new ClipboardAddon(undefined, clipboardProvider);
const unicode11Addon = new Unicode11Addon(); const unicode11Addon = new Unicode11Addon();
const webLinksAddon = new WebLinksAddon((_event, uri) => { const webLinksAddon = new WebLinksAddon((_event, uri) => {
const url = uri.startsWith("http://") || uri.startsWith("https://") const url =
uri.startsWith("http://") || uri.startsWith("https://")
? uri ? uri
: `https://${uri}`; : `https://${uri}`;
window.open(url, "_blank"); window.open(url, "_blank");
@@ -2148,7 +2149,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
} }
const persistenceEnabled = const persistenceEnabled =
localStorage.getItem("enableTerminalSessionPersistence") !== "false"; localStorage.getItem("enableTerminalSessionPersistence") !==
"false";
if ( if (
!persistenceEnabled && !persistenceEnabled &&
sessionIdRef.current && sessionIdRef.current &&
+4 -1
View File
@@ -98,7 +98,8 @@ export const FOLDER_COLORS = [
export const SPLIT_MODES: { id: SplitMode; label: string }[] = [ export const SPLIT_MODES: { id: SplitMode; label: string }[] = [
{ id: "none", label: "None" }, { id: "none", label: "None" },
{ id: "2-way", label: "2-Way" }, { 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: "4-way", label: "4-Way" },
{ id: "5-way", label: "5-Way" }, { id: "5-way", label: "5-Way" },
{ id: "6-way", label: "6-Way" }, { id: "6-way", label: "6-Way" },
@@ -108,6 +109,7 @@ export const PANE_COUNTS: Record<SplitMode, number> = {
none: 0, none: 0,
"2-way": 2, "2-way": 2,
"3-way": 3, "3-way": 3,
"3-way-horizontal": 3,
"4-way": 4, "4-way": 4,
"5-way": 5, "5-way": 5,
"6-way": 6, "6-way": 6,
@@ -117,6 +119,7 @@ export const PANE_LAYOUTS: Record<SplitMode, string> = {
none: "", none: "",
"2-way": "grid-cols-2 grid-rows-1", "2-way": "grid-cols-2 grid-rows-1",
"3-way": "grid-cols-2 grid-rows-2", "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", "4-way": "grid-cols-2 grid-rows-2",
"5-way": "grid-cols-3 grid-rows-2", "5-way": "grid-cols-3 grid-rows-2",
"6-way": "grid-cols-3 grid-rows-2", "6-way": "grid-cols-3 grid-rows-2",
+1
View File
@@ -1228,6 +1228,7 @@
"failedCompleteReset": "Failed to complete password reset", "failedCompleteReset": "Failed to complete password reset",
"invalidTotpCode": "Invalid TOTP code", "invalidTotpCode": "Invalid TOTP code",
"failedOidcLogin": "Failed to start OIDC login", "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", "failedUserInfo": "Failed to get user info after login",
"oidcAuthFailed": "OIDC authentication failed", "oidcAuthFailed": "OIDC authentication failed",
"invalidAuthUrl": "Invalid authorization URL received from backend", "invalidAuthUrl": "Invalid authorization URL received from backend",
+1
View File
@@ -2336,6 +2336,7 @@
"failedCompleteReset": "密码重置失败", "failedCompleteReset": "密码重置失败",
"invalidTotpCode": "无效的 TOTP 代码", "invalidTotpCode": "无效的 TOTP 代码",
"failedOidcLogin": "OIDC 登录启动失败", "failedOidcLogin": "OIDC 登录启动失败",
"silentSigninOidcUnavailable": "已请求静默登录,但当前未启用 OIDC 登录。",
"failedUserInfo": "登录后无法获取用户信息", "failedUserInfo": "登录后无法获取用户信息",
"oidcAuthFailed": "OIDC 身份验证失败", "oidcAuthFailed": "OIDC 身份验证失败",
"noTokenReceived": "登录未收到令牌", "noTokenReceived": "登录未收到令牌",
+22
View File
@@ -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<Record<string, unknown>> {
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( export async function updatePasswordLoginAllowed(
allowed: boolean, allowed: boolean,
): Promise<{ allowed: boolean }> { ): Promise<{ allowed: boolean }> {
+32
View File
@@ -19,6 +19,8 @@ function defaultSizes(mode: SplitMode): {
return { rowSizes: [100], rowColSizes: [[50, 50]] }; return { rowSizes: [100], rowColSizes: [[50, 50]] };
case "3-way": case "3-way":
return { rowSizes: [50, 50], rowColSizes: [[50, 50], [100]] }; return { rowSizes: [50, 50], rowColSizes: [[50, 50], [100]] };
case "3-way-horizontal":
return { rowSizes: [50, 50], rowColSizes: [[50, 50], [100]] };
case "4-way": case "4-way":
return { return {
rowSizes: [50, 50], rowSizes: [50, 50],
@@ -444,6 +446,36 @@ export function SplitView({
</div> </div>
)} )}
{splitMode === "3-way-horizontal" && (
<div className="flex flex-col w-full h-full min-h-0">
<div
className="flex min-h-0 overflow-hidden"
style={{ height: `${rowSizes[0]}%` }}
>
<div
className="min-w-0 min-h-0 overflow-hidden"
style={{ width: `${rowColSizes[0][0]}%` }}
>
{pane(0)}
</div>
<ColDivider
onMouseDown={(e) => onColDivider(e, 0, 0)}
onTouchStart={(e) => onColDividerTouch(e, 0, 0)}
/>
<div className="flex-1 min-w-0 min-h-0 overflow-hidden">
{pane(1)}
</div>
</div>
<RowDivider
onMouseDown={(e) => onRowDivider(e, 0)}
onTouchStart={(e) => onRowDividerTouch(e, 0)}
/>
<div className="flex-1 min-h-0 overflow-hidden">
{pane(2)}
</div>
</div>
)}
{splitMode === "4-way" && ( {splitMode === "4-way" && (
<div className="flex flex-col w-full h-full min-h-0"> <div className="flex flex-col w-full h-full min-h-0">
<Row rowIdx={0} paneIndices={[0, 1]} /> <Row rowIdx={0} paneIndices={[0, 1]} />
+27 -1
View File
@@ -31,6 +31,8 @@ import {
getAdminOIDCConfig, getAdminOIDCConfig,
updateOIDCConfig, updateOIDCConfig,
disableOIDCConfig, disableOIDCConfig,
getOidcAutoProvision,
updateOidcAutoProvision,
isElectron, isElectron,
getUserRoles, getUserRoles,
assignRoleToUser, assignRoleToUser,
@@ -134,6 +136,7 @@ export function AdminSettingsPanel() {
const [logLevel, setLogLevel] = useState("info"); const [logLevel, setLogLevel] = useState("info");
// OIDC state // OIDC state
const [oidcAutoProvision, setOidcAutoProvision] = useState(false);
const [oidcClientId, setOidcClientId] = useState(""); const [oidcClientId, setOidcClientId] = useState("");
const [oidcClientSecret, setOidcClientSecret] = useState(""); const [oidcClientSecret, setOidcClientSecret] = useState("");
const [oidcAuthUrl, setOidcAuthUrl] = useState(""); const [oidcAuthUrl, setOidcAuthUrl] = useState("");
@@ -238,7 +241,7 @@ export function AdminSettingsPanel() {
async function loadGeneralSettings() { async function loadGeneralSettings() {
try { try {
const [reg, pwLogin, pwReset, timeout, monitoring, level, guac] = const [reg, pwLogin, pwReset, timeout, monitoring, level, guac, oidcProv] =
await Promise.allSettled([ await Promise.allSettled([
getRegistrationAllowed(), getRegistrationAllowed(),
getPasswordLoginAllowed(), getPasswordLoginAllowed(),
@@ -247,11 +250,14 @@ export function AdminSettingsPanel() {
getGlobalMonitoringSettings(), getGlobalMonitoringSettings(),
getLogLevel(), getLogLevel(),
getGuacamoleSettings(), getGuacamoleSettings(),
getOidcAutoProvision(),
]); ]);
if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed); if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed);
if (pwLogin.status === "fulfilled") if (pwLogin.status === "fulfilled")
setAllowPasswordLogin(pwLogin.value.allowed); setAllowPasswordLogin(pwLogin.value.allowed);
if (oidcProv.status === "fulfilled")
setOidcAutoProvision(oidcProv.value.enabled);
if (pwReset.status === "fulfilled") setAllowPasswordReset(pwReset.value); if (pwReset.status === "fulfilled") setAllowPasswordReset(pwReset.value);
if (timeout.status === "fulfilled") if (timeout.status === "fulfilled")
setSessionTimeout(String(timeout.value.timeoutHours)); 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() { async function handleTogglePasswordReset() {
const newVal = !allowPasswordReset; const newVal = !allowPasswordReset;
setAllowPasswordReset(newVal); setAllowPasswordReset(newVal);
@@ -700,6 +717,15 @@ export function AdminSettingsPanel() {
onToggle={handleTogglePasswordLogin} onToggle={handleTogglePasswordLogin}
/> />
</SettingRow> </SettingRow>
<SettingRow
label="OIDC Auto-Provision"
description="Auto-create accounts for OIDC users even when registration is disabled"
>
<AdminToggle
on={oidcAutoProvision}
onToggle={handleToggleOidcAutoProvision}
/>
</SettingRow>
<SettingRow <SettingRow
label="Allow Password Reset" label="Allow Password Reset"
description="Reset code via Docker logs" description="Reset code via Docker logs"
+9
View File
@@ -24,6 +24,15 @@ const LAYOUT_PREVIEWS: Record<SplitMode, React.ReactNode> = {
</div> </div>
</div> </div>
), ),
"3-way-horizontal": (
<div className="flex flex-col gap-0.5 size-full">
<div className="flex gap-0.5 flex-1">
<div className="flex-1 border-2 border-current" />
<div className="flex-1 border-2 border-current" />
</div>
<div className="flex-1 border-2 border-current" />
</div>
),
"4-way": ( "4-way": (
<div className="grid grid-cols-2 grid-rows-2 gap-0.5 size-full"> <div className="grid grid-cols-2 grid-rows-2 gap-0.5 size-full">
<div className="border-2 border-current" /> <div className="border-2 border-current" />