mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 21:03:47 +00:00
Merge remote-tracking branch 'origin/dev-2.3.0' into dev-2.3.0
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
services:
|
|
||||||
services:
|
services:
|
||||||
termix:
|
termix:
|
||||||
image: ghcr.io/lukegus/termix:latest
|
image: ghcr.io/lukegus/termix:latest
|
||||||
|
|||||||
+31
-10
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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}` : "";
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -2336,6 +2336,7 @@
|
|||||||
"failedCompleteReset": "密码重置失败",
|
"failedCompleteReset": "密码重置失败",
|
||||||
"invalidTotpCode": "无效的 TOTP 代码",
|
"invalidTotpCode": "无效的 TOTP 代码",
|
||||||
"failedOidcLogin": "OIDC 登录启动失败",
|
"failedOidcLogin": "OIDC 登录启动失败",
|
||||||
|
"silentSigninOidcUnavailable": "已请求静默登录,但当前未启用 OIDC 登录。",
|
||||||
"failedUserInfo": "登录后无法获取用户信息",
|
"failedUserInfo": "登录后无法获取用户信息",
|
||||||
"oidcAuthFailed": "OIDC 身份验证失败",
|
"oidcAuthFailed": "OIDC 身份验证失败",
|
||||||
"noTokenReceived": "登录未收到令牌",
|
"noTokenReceived": "登录未收到令牌",
|
||||||
|
|||||||
@@ -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 }> {
|
||||||
|
|||||||
@@ -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]} />
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user