mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
v2.3.0
This commit is contained in:
@@ -470,7 +470,7 @@ app.post("/dashboard/preferences", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const { cards } = req.body;
|
||||
const { cards, mainWidthPct } = req.body;
|
||||
|
||||
if (!cards || !Array.isArray(cards)) {
|
||||
return res.status(400).json({
|
||||
@@ -478,7 +478,11 @@ app.post("/dashboard/preferences", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const layout = JSON.stringify({ cards });
|
||||
const layoutObj: Record<string, unknown> = { cards };
|
||||
if (typeof mainWidthPct === "number") {
|
||||
layoutObj.mainWidthPct = mainWidthPct;
|
||||
}
|
||||
const layout = JSON.stringify(layoutObj);
|
||||
|
||||
const existing = await getDb()
|
||||
.select()
|
||||
|
||||
@@ -1251,6 +1251,7 @@ app.post(
|
||||
};
|
||||
|
||||
try {
|
||||
mainDb.$client.exec("PRAGMA foreign_keys = OFF");
|
||||
try {
|
||||
const importedHosts = importDb
|
||||
.prepare("SELECT * FROM ssh_data")
|
||||
@@ -1561,6 +1562,7 @@ app.post(
|
||||
);
|
||||
}
|
||||
|
||||
mainDb.$client.exec("PRAGMA foreign_keys = ON");
|
||||
result.success = true;
|
||||
|
||||
try {
|
||||
@@ -2019,12 +2021,15 @@ httpServer.on("error", (err: NodeJS.ErrnoException) => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
httpServer.listen(HTTP_PORT, async () => {
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
export const serverReady = new Promise<void>((resolve) => {
|
||||
httpServer.listen(HTTP_PORT, async () => {
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
await initializeSecurity();
|
||||
await initializeSecurity();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const sslConfig = AutoSSLSetup.getSSLConfig();
|
||||
|
||||
@@ -710,6 +710,8 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_credentials", "cert_public_key", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_credentials", "system_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "system_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "system_key_password", "TEXT");
|
||||
@@ -782,6 +784,7 @@ const migrateSchema = () => {
|
||||
|
||||
addColumnIfNotExists("snippets", "folder", "TEXT");
|
||||
addColumnIfNotExists("snippets", "order", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("snippets", "host_filter", "TEXT");
|
||||
|
||||
try {
|
||||
sqlite
|
||||
@@ -1007,6 +1010,19 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT override_credential_id FROM host_access LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec("ALTER TABLE host_access ADD COLUMN override_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL");
|
||||
} catch (alterError) {
|
||||
databaseLogger.warn("Failed to add override_credential_id column", {
|
||||
operation: "schema_migration",
|
||||
error: alterError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT sudo_password FROM ssh_data LIMIT 1").get();
|
||||
} catch {
|
||||
@@ -1078,6 +1094,39 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Copy unencrypted username/domain into protocol-specific columns for old guac hosts.
|
||||
// Passwords are handled via the legacy field name fallback in lazy-field-encryption.ts.
|
||||
const usernameDomainBackfills = [
|
||||
{
|
||||
protocol: "rdp",
|
||||
sql: "UPDATE ssh_data SET rdp_user = username, rdp_password = password, rdp_domain = domain WHERE connection_type = 'rdp' AND rdp_user IS NULL AND rdp_password IS NULL",
|
||||
},
|
||||
{
|
||||
protocol: "vnc",
|
||||
sql: "UPDATE ssh_data SET vnc_user = username, vnc_password = password WHERE connection_type = 'vnc' AND vnc_user IS NULL AND vnc_password IS NULL",
|
||||
},
|
||||
{
|
||||
protocol: "telnet",
|
||||
sql: "UPDATE ssh_data SET telnet_user = username, telnet_password = password WHERE connection_type = 'telnet' AND telnet_user IS NULL AND telnet_password IS NULL",
|
||||
},
|
||||
];
|
||||
for (const backfill of usernameDomainBackfills) {
|
||||
try {
|
||||
const result = sqlite.prepare(backfill.sql).run();
|
||||
if (result.changes > 0) {
|
||||
databaseLogger.info(
|
||||
`Backfilled ${result.changes} ${backfill.protocol} host credential(s)`,
|
||||
{ operation: "guac_credential_backfill" },
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn(`Failed to backfill ${backfill.protocol} host credentials`, {
|
||||
operation: "guac_credential_backfill",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM roles LIMIT 1").get();
|
||||
} catch {
|
||||
|
||||
@@ -256,6 +256,8 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
|
||||
keyType: text("key_type"),
|
||||
detectedKeyType: text("detected_key_type"),
|
||||
|
||||
certPublicKey: text("cert_public_key", { length: 8192 }),
|
||||
|
||||
systemPassword: text("system_password"),
|
||||
systemKey: text("system_key", { length: 16384 }),
|
||||
systemKeyPassword: text("system_key_password"),
|
||||
@@ -302,6 +304,7 @@ export const snippets = sqliteTable("snippets", {
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
hostFilter: text("host_filter"),
|
||||
});
|
||||
|
||||
export const snippetFolders = sqliteTable("snippet_folders", {
|
||||
@@ -461,6 +464,10 @@ export const hostAccess = sqliteTable("host_access", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
lastAccessedAt: text("last_accessed_at"),
|
||||
accessCount: integer("access_count").notNull().default(0),
|
||||
overrideCredentialId: integer("override_credential_id").references(
|
||||
() => sshCredentials.id,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
});
|
||||
|
||||
export const sharedCredentials = sqliteTable("shared_credentials", {
|
||||
|
||||
@@ -147,6 +147,7 @@ router.post(
|
||||
key,
|
||||
keyPassword,
|
||||
keyType,
|
||||
certPublicKey,
|
||||
} = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !isNonEmptyString(name)) {
|
||||
@@ -230,6 +231,8 @@ router.post(
|
||||
keyPassword: plainKeyPassword,
|
||||
keyType: keyType || null,
|
||||
detectedKeyType: keyInfo?.keyType || null,
|
||||
certPublicKey:
|
||||
authType === "key" && certPublicKey ? certPublicKey.trim() : null,
|
||||
usageCount: 0,
|
||||
lastUsed: null,
|
||||
};
|
||||
@@ -431,15 +434,14 @@ router.get(
|
||||
if (credential.password) {
|
||||
output.password = credential.password;
|
||||
}
|
||||
if (credential.key) {
|
||||
output.key = credential.key;
|
||||
}
|
||||
if (credential.privateKey) {
|
||||
output.privateKey = credential.privateKey;
|
||||
}
|
||||
output.hasKey = !!credential.key;
|
||||
output.hasKeyPassword = !!credential.keyPassword;
|
||||
if (credential.publicKey) {
|
||||
output.publicKey = credential.publicKey;
|
||||
}
|
||||
if (credential.certPublicKey) {
|
||||
output.certPublicKey = credential.certPublicKey;
|
||||
}
|
||||
if (credential.keyPassword) {
|
||||
output.keyPassword = credential.keyPassword;
|
||||
}
|
||||
@@ -572,6 +574,9 @@ router.put(
|
||||
if (updateData.keyPassword !== undefined) {
|
||||
updateFields.keyPassword = updateData.keyPassword || null;
|
||||
}
|
||||
if (updateData.certPublicKey !== undefined) {
|
||||
updateFields.certPublicKey = updateData.certPublicKey?.trim() || null;
|
||||
}
|
||||
|
||||
if (Object.keys(updateFields).length === 0) {
|
||||
const existing = await SimpleDBOps.select(
|
||||
@@ -947,6 +952,7 @@ function formatCredentialOutput(
|
||||
authType: credential.authType,
|
||||
username: credential.username || null,
|
||||
publicKey: credential.publicKey,
|
||||
hasCertPublicKey: !!credential.certPublicKey,
|
||||
keyType: credential.keyType,
|
||||
detectedKeyType: credential.detectedKeyType,
|
||||
usageCount: credential.usageCount || 0,
|
||||
@@ -1445,7 +1451,7 @@ router.post(
|
||||
const publicKeyString =
|
||||
typeof publicKeyPem === "string"
|
||||
? publicKeyPem
|
||||
: publicKeyPem.toString("utf8");
|
||||
: (publicKeyPem as Buffer).toString("utf8");
|
||||
|
||||
let keyType = "unknown";
|
||||
const asymmetricKeyType = privateKeyObj.asymmetricKeyType;
|
||||
@@ -1617,9 +1623,12 @@ async function deploySSHKeyToHost(
|
||||
const escapedKey = actualPublicKey
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/'/g, "'\\''");
|
||||
const escapedName = credData.name
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/'/g, "'\\''");
|
||||
|
||||
conn.exec(
|
||||
`printf '%s\n' '${escapedKey} ${credData.name}@Termix' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`,
|
||||
`printf '%s\n' '${escapedKey} ${escapedName}@Termix' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(addTimeout);
|
||||
|
||||
@@ -43,6 +43,8 @@ const router = express.Router();
|
||||
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
const STATS_SERVER_URL = "http://localhost:30005";
|
||||
|
||||
function notifyStatsHostUpdated(
|
||||
hostId: number,
|
||||
headers: Pick<Request["headers"], "authorization" | "cookie">,
|
||||
@@ -50,7 +52,7 @@ function notifyStatsHostUpdated(
|
||||
): void {
|
||||
axios
|
||||
.post(
|
||||
"http://localhost:30005/host-updated",
|
||||
`${STATS_SERVER_URL}/host-updated`,
|
||||
{ hostId },
|
||||
{
|
||||
headers: {
|
||||
@@ -244,26 +246,18 @@ function normalizeImportedHost(
|
||||
}
|
||||
|
||||
const SENSITIVE_FIELDS = [
|
||||
"password",
|
||||
"key",
|
||||
"keyPassword",
|
||||
"sudoPassword",
|
||||
"autostartPassword",
|
||||
"autostartKey",
|
||||
"autostartKeyPassword",
|
||||
"socks5Password",
|
||||
"rdpPassword",
|
||||
"vncPassword",
|
||||
"telnetPassword",
|
||||
];
|
||||
|
||||
function stripSensitiveFields(
|
||||
host: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const result = { ...host };
|
||||
result.hasPassword = !!host.password;
|
||||
result.hasKey = !!host.key;
|
||||
result.hasSudoPassword = !!host.sudoPassword;
|
||||
result.hasKeyPassword = !!host.keyPassword;
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
delete result[field];
|
||||
}
|
||||
@@ -291,10 +285,23 @@ function transformHostResponse(
|
||||
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
||||
showDockerInSidebar: !!host.showDockerInSidebar,
|
||||
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
|
||||
enableSsh: host.enableSsh !== undefined ? !!host.enableSsh : true,
|
||||
enableRdp: !!host.enableRdp,
|
||||
enableVnc: !!host.enableVnc,
|
||||
enableTelnet: !!host.enableTelnet,
|
||||
// Old hosts only had connection_type set; the per-protocol enable flags didn't exist yet.
|
||||
// The schema defaults (enableSsh=true, others=false) wrongly mark every old host as SSH.
|
||||
// Detect this migration case: if no non-SSH protocol is explicitly enabled AND
|
||||
// connectionType is set to a non-SSH value, fall back to inferring from connectionType.
|
||||
...(() => {
|
||||
const ct = host.connectionType;
|
||||
const rdp = !!host.enableRdp;
|
||||
const vnc = !!host.enableVnc;
|
||||
const tel = !!host.enableTelnet;
|
||||
const isMigratedNonSsh = !rdp && !vnc && !tel && ct && ct !== "ssh";
|
||||
return {
|
||||
enableSsh: isMigratedNonSsh ? false : !!host.enableSsh,
|
||||
enableRdp: isMigratedNonSsh ? ct === "rdp" : rdp,
|
||||
enableVnc: isMigratedNonSsh ? ct === "vnc" : vnc,
|
||||
enableTelnet: isMigratedNonSsh ? ct === "telnet" : tel,
|
||||
};
|
||||
})(),
|
||||
sshPort: host.sshPort ?? host.port ?? 22,
|
||||
rdpPort: host.rdpPort ?? 3389,
|
||||
vncPort: host.vncPort ?? 5900,
|
||||
@@ -305,9 +312,6 @@ function transformHostResponse(
|
||||
rdpIgnoreCert: !!host.rdpIgnoreCert,
|
||||
vncUser: host.vncUser || undefined,
|
||||
telnetUser: host.telnetUser || undefined,
|
||||
hasRdpPassword: !!host.rdpPassword,
|
||||
hasVncPassword: !!host.vncPassword,
|
||||
hasTelnetPassword: !!host.telnetPassword,
|
||||
tunnelConnections: host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections as string)
|
||||
: [],
|
||||
@@ -658,15 +662,19 @@ router.post(
|
||||
authType ||
|
||||
authMethod ||
|
||||
(effectiveConnectionType !== "ssh" ? "password" : undefined);
|
||||
const effectiveUsername =
|
||||
username || rdpUser || vncUser || telnetUser || "";
|
||||
const effectiveName =
|
||||
name || (effectiveUsername ? `${effectiveUsername}@${ip}` : String(ip));
|
||||
const sshDataObj: Record<string, unknown> = {
|
||||
userId: userId,
|
||||
connectionType: effectiveConnectionType,
|
||||
name,
|
||||
name: effectiveName,
|
||||
folder: folder || null,
|
||||
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
username: effectiveUsername,
|
||||
authType: effectiveAuthType,
|
||||
credentialId: credentialId || null,
|
||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||
@@ -722,7 +730,7 @@ router.post(
|
||||
portKnockSequence: portKnockSequence
|
||||
? JSON.stringify(portKnockSequence)
|
||||
: null,
|
||||
enableSsh: enableSsh !== false ? 1 : 0,
|
||||
enableSsh: enableSsh ? 1 : 0,
|
||||
enableRdp: enableRdp ? 1 : 0,
|
||||
enableVnc: enableVnc ? 1 : 0,
|
||||
enableTelnet: enableTelnet ? 1 : 0,
|
||||
@@ -1190,14 +1198,18 @@ router.put(
|
||||
}
|
||||
|
||||
const effectiveAuthType = authType || authMethod;
|
||||
const effectiveUsername =
|
||||
username || rdpUser || vncUser || telnetUser || "";
|
||||
const effectiveName =
|
||||
name || (effectiveUsername ? `${effectiveUsername}@${ip}` : String(ip));
|
||||
const sshDataObj: Record<string, unknown> = {
|
||||
connectionType: connectionType || "ssh",
|
||||
name,
|
||||
name: effectiveName,
|
||||
folder,
|
||||
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
username: effectiveUsername,
|
||||
authType: effectiveAuthType,
|
||||
credentialId: credentialId || null,
|
||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||
@@ -1253,7 +1265,7 @@ router.put(
|
||||
portKnockSequence: portKnockSequence
|
||||
? JSON.stringify(portKnockSequence)
|
||||
: null,
|
||||
enableSsh: enableSsh !== false ? 1 : 0,
|
||||
enableSsh: enableSsh ? 1 : 0,
|
||||
enableRdp: enableRdp ? 1 : 0,
|
||||
enableVnc: enableVnc ? 1 : 0,
|
||||
enableTelnet: enableTelnet ? 1 : 0,
|
||||
@@ -1566,6 +1578,23 @@ router.get(
|
||||
guacamoleConfig: hosts.guacamoleConfig,
|
||||
macAddress: hosts.macAddress,
|
||||
dockerConfig: hosts.dockerConfig,
|
||||
enableSsh: hosts.enableSsh,
|
||||
enableRdp: hosts.enableRdp,
|
||||
enableVnc: hosts.enableVnc,
|
||||
enableTelnet: hosts.enableTelnet,
|
||||
sshPort: hosts.sshPort,
|
||||
rdpPort: hosts.rdpPort,
|
||||
vncPort: hosts.vncPort,
|
||||
telnetPort: hosts.telnetPort,
|
||||
rdpUser: hosts.rdpUser,
|
||||
rdpPassword: hosts.rdpPassword,
|
||||
rdpDomain: hosts.rdpDomain,
|
||||
rdpSecurity: hosts.rdpSecurity,
|
||||
rdpIgnoreCert: hosts.rdpIgnoreCert,
|
||||
vncUser: hosts.vncUser,
|
||||
vncPassword: hosts.vncPassword,
|
||||
telnetUser: hosts.telnetUser,
|
||||
telnetPassword: hosts.telnetPassword,
|
||||
|
||||
ownerId: hosts.userId,
|
||||
isShared: sql<boolean>`${hostAccess.id} IS NOT NULL AND ${hosts.userId} != ${userId}`,
|
||||
@@ -1891,9 +1920,21 @@ router.get(
|
||||
const exportData = isRemoteDesktop
|
||||
? {
|
||||
...baseExportData,
|
||||
domain: resolvedHost.domain || null,
|
||||
security: resolvedHost.security || null,
|
||||
ignoreCert: !!resolvedHost.ignoreCert,
|
||||
enableRdp: !!resolvedHost.enableRdp,
|
||||
enableVnc: !!resolvedHost.enableVnc,
|
||||
enableTelnet: !!resolvedHost.enableTelnet,
|
||||
rdpPort: resolvedHost.rdpPort || 3389,
|
||||
vncPort: resolvedHost.vncPort || 5900,
|
||||
telnetPort: resolvedHost.telnetPort || 23,
|
||||
rdpUser: resolvedHost.rdpUser || null,
|
||||
rdpPassword: resolvedHost.rdpPassword || null,
|
||||
rdpDomain: resolvedHost.rdpDomain || null,
|
||||
rdpSecurity: resolvedHost.rdpSecurity || null,
|
||||
rdpIgnoreCert: !!resolvedHost.rdpIgnoreCert,
|
||||
vncUser: resolvedHost.vncUser || null,
|
||||
vncPassword: resolvedHost.vncPassword || null,
|
||||
telnetUser: resolvedHost.telnetUser || null,
|
||||
telnetPassword: resolvedHost.telnetPassword || null,
|
||||
guacamoleConfig: resolvedHost.guacamoleConfig
|
||||
? JSON.parse(resolvedHost.guacamoleConfig as string)
|
||||
: null,
|
||||
@@ -2217,9 +2258,8 @@ router.delete(
|
||||
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
const statsPort = 30005;
|
||||
await axios.post(
|
||||
`http://localhost:${statsPort}/host-deleted`,
|
||||
`${STATS_SERVER_URL}/host-deleted`,
|
||||
{ hostId: numericHostId },
|
||||
{
|
||||
headers: {
|
||||
@@ -3394,11 +3434,10 @@ router.delete(
|
||||
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
const statsPort = 30005;
|
||||
for (const host of hostsToDelete) {
|
||||
try {
|
||||
await axios.post(
|
||||
`http://localhost:${statsPort}/host-deleted`,
|
||||
`${STATS_SERVER_URL}/host-deleted`,
|
||||
{ hostId: host.id },
|
||||
{
|
||||
headers: {
|
||||
@@ -3804,6 +3843,10 @@ router.post(
|
||||
overrideCredentialUsername: hostData.overrideCredentialUsername
|
||||
? 1
|
||||
: 0,
|
||||
enableSsh: hostData.enableSsh ?? false,
|
||||
enableRdp: hostData.enableRdp ?? false,
|
||||
enableVnc: hostData.enableVnc ?? false,
|
||||
enableTelnet: hostData.enableTelnet ?? false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -3814,9 +3857,21 @@ router.post(
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
sshDataObj.domain = hostData.domain || null;
|
||||
sshDataObj.security = hostData.security || null;
|
||||
sshDataObj.ignoreCert = hostData.ignoreCert ? 1 : 0;
|
||||
sshDataObj.rdpUser = hostData.rdpUser || null;
|
||||
sshDataObj.rdpPassword = hostData.rdpPassword || null;
|
||||
sshDataObj.rdpDomain = hostData.rdpDomain || null;
|
||||
sshDataObj.rdpSecurity = hostData.rdpSecurity || null;
|
||||
sshDataObj.rdpIgnoreCert = hostData.rdpIgnoreCert ? 1 : 0;
|
||||
sshDataObj.rdpPort = hostData.rdpPort || 3389;
|
||||
sshDataObj.vncUser = hostData.vncUser || null;
|
||||
sshDataObj.vncPassword = hostData.vncPassword || null;
|
||||
sshDataObj.vncPort = hostData.vncPort || 5900;
|
||||
sshDataObj.telnetUser = hostData.telnetUser || null;
|
||||
sshDataObj.telnetPassword = hostData.telnetPassword || null;
|
||||
sshDataObj.telnetPort = hostData.telnetPort || 23;
|
||||
sshDataObj.enableRdp = hostData.enableRdp ? 1 : 0;
|
||||
sshDataObj.enableVnc = hostData.enableVnc ? 1 : 0;
|
||||
sshDataObj.enableTelnet = hostData.enableTelnet ? 1 : 0;
|
||||
sshDataObj.guacamoleConfig = hostData.guacamoleConfig
|
||||
? JSON.stringify(hostData.guacamoleConfig)
|
||||
: null;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
sharedCredentials,
|
||||
snippets,
|
||||
snippetAccess,
|
||||
sshCredentials,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and, desc, sql, or, isNull, gte } from "drizzle-orm";
|
||||
import type { Response } from "express";
|
||||
@@ -128,10 +129,10 @@ router.post(
|
||||
return res.status(403).json({ error: "Not host owner" });
|
||||
}
|
||||
|
||||
if (!host[0].credentialId) {
|
||||
if (!host[0].credentialId && host[0].authType !== "opkssh") {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Only hosts using credentials can be shared. Please create a credential and assign it to this host before sharing.",
|
||||
"Only hosts using credentials or OPKSSH can be shared. Please create a credential and assign it to this host before sharing.",
|
||||
code: "CREDENTIAL_REQUIRED_FOR_SHARING",
|
||||
});
|
||||
}
|
||||
@@ -203,23 +204,25 @@ router.post(
|
||||
.delete(sharedCredentials)
|
||||
.where(eq(sharedCredentials.hostAccessId, existing[0].id));
|
||||
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
if (targetType === "user") {
|
||||
await sharedCredManager.createSharedCredentialForUser(
|
||||
existing[0].id,
|
||||
host[0].credentialId,
|
||||
targetUserId!,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await sharedCredManager.createSharedCredentialsForRole(
|
||||
existing[0].id,
|
||||
host[0].credentialId,
|
||||
targetRoleId!,
|
||||
userId,
|
||||
);
|
||||
if (host[0].credentialId) {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
if (targetType === "user") {
|
||||
await sharedCredManager.createSharedCredentialForUser(
|
||||
existing[0].id,
|
||||
host[0].credentialId,
|
||||
targetUserId!,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await sharedCredManager.createSharedCredentialsForRole(
|
||||
existing[0].id,
|
||||
host[0].credentialId,
|
||||
targetRoleId!,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
databaseLogger.info("Permission granted", {
|
||||
operation: "rbac_permission_grant",
|
||||
@@ -249,20 +252,22 @@ router.post(
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
|
||||
if (targetType === "user") {
|
||||
await sharedCredManager.createSharedCredentialForUser(
|
||||
result.lastInsertRowid as number,
|
||||
host[0].credentialId,
|
||||
targetUserId!,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await sharedCredManager.createSharedCredentialsForRole(
|
||||
result.lastInsertRowid as number,
|
||||
host[0].credentialId,
|
||||
targetRoleId!,
|
||||
userId,
|
||||
);
|
||||
if (host[0].credentialId) {
|
||||
if (targetType === "user") {
|
||||
await sharedCredManager.createSharedCredentialForUser(
|
||||
result.lastInsertRowid as number,
|
||||
host[0].credentialId,
|
||||
targetUserId!,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await sharedCredManager.createSharedCredentialsForRole(
|
||||
result.lastInsertRowid as number,
|
||||
host[0].credentialId,
|
||||
targetRoleId!,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
databaseLogger.success("Host shared successfully", {
|
||||
operation: "rbac_host_share_success",
|
||||
@@ -1520,4 +1525,58 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
router.put(
|
||||
"/host-access/:hostId/credential",
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const hostId = Number.parseInt(String(req.params.hostId), 10);
|
||||
const { credentialId } = req.body;
|
||||
|
||||
if (!hostId || isNaN(hostId)) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
|
||||
const access = await db
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.where(
|
||||
and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (access.length === 0) {
|
||||
return res.status(403).json({ error: "No access to this host" });
|
||||
}
|
||||
|
||||
if (credentialId) {
|
||||
const cred = await db
|
||||
.select({ id: sshCredentials.id })
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (cred.length === 0) {
|
||||
return res.status(404).json({ error: "Credential not found" });
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(hostAccess)
|
||||
.set({ overrideCredentialId: credentialId || null })
|
||||
.where(eq(hostAccess.id, access[0].id));
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to set override credential", error);
|
||||
res.status(500).json({ error: "Failed to update credential" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -93,6 +93,7 @@ async function getAccessibleSnippet(snippetId: number, userId: string) {
|
||||
order: snippets.order,
|
||||
createdAt: snippets.createdAt,
|
||||
updatedAt: snippets.updatedAt,
|
||||
hostFilter: snippets.hostFilter,
|
||||
})
|
||||
.from(snippetAccess)
|
||||
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
|
||||
@@ -1104,7 +1105,7 @@ router.post(
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, content, description, folder, order } = req.body;
|
||||
const { name, content, description, folder, order, hostFilter } = req.body;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(userId) ||
|
||||
@@ -1146,6 +1147,7 @@ router.post(
|
||||
description: description?.trim() || null,
|
||||
folder: folder?.trim() || null,
|
||||
order: snippetOrder,
|
||||
hostFilter: hostFilter ? JSON.stringify(hostFilter) : null,
|
||||
};
|
||||
|
||||
const result = await db.insert(snippets).values(insertData).returning();
|
||||
@@ -1238,6 +1240,7 @@ router.put(
|
||||
description: string | null;
|
||||
folder: string | null;
|
||||
order: number;
|
||||
hostFilter: string | null;
|
||||
}> = {
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
};
|
||||
@@ -1251,6 +1254,10 @@ router.put(
|
||||
if (updateData.folder !== undefined)
|
||||
updateFields.folder = updateData.folder?.trim() || null;
|
||||
if (updateData.order !== undefined) updateFields.order = updateData.order;
|
||||
if (updateData.hostFilter !== undefined)
|
||||
updateFields.hostFilter = updateData.hostFilter
|
||||
? JSON.stringify(updateData.hostFilter)
|
||||
: null;
|
||||
|
||||
await db
|
||||
.update(snippets)
|
||||
|
||||
@@ -521,6 +521,7 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => {
|
||||
name_path,
|
||||
scopes,
|
||||
allowed_users,
|
||||
admin_group,
|
||||
} = req.body;
|
||||
|
||||
const isDisableRequest =
|
||||
@@ -579,6 +580,7 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => {
|
||||
name_path,
|
||||
scopes: scopes || "openid email profile",
|
||||
allowed_users: allowed_users || "",
|
||||
admin_group: admin_group || "",
|
||||
};
|
||||
|
||||
let encryptedConfig;
|
||||
@@ -817,7 +819,7 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => {
|
||||
*/
|
||||
router.get("/oidc/authorize", async (req, res) => {
|
||||
try {
|
||||
const { rememberMe } = req.query;
|
||||
const { rememberMe, desktopCallbackPort } = req.query;
|
||||
const origin = getRequestOriginWithForceHTTPS(req);
|
||||
const backendCallbackUri = `${origin}/users/oidc/callback`;
|
||||
|
||||
@@ -840,7 +842,9 @@ router.get("/oidc/authorize", async (req, res) => {
|
||||
|
||||
const referer = req.get("Referer");
|
||||
let frontendOrigin;
|
||||
if (referer) {
|
||||
if (desktopCallbackPort) {
|
||||
frontendOrigin = `http://127.0.0.1:${desktopCallbackPort}/oidc-callback`;
|
||||
} else if (referer) {
|
||||
const refererUrl = new URL(referer);
|
||||
frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}`;
|
||||
} else {
|
||||
@@ -949,6 +953,18 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
config = JSON.parse(
|
||||
(configRow as Record<string, unknown>).value as string,
|
||||
);
|
||||
|
||||
if (config.client_secret?.startsWith("encrypted:")) {
|
||||
config.client_secret = Buffer.from(
|
||||
config.client_secret.substring(10),
|
||||
"base64",
|
||||
).toString("utf8");
|
||||
} else if (config.client_secret?.startsWith("encoded:")) {
|
||||
config.client_secret = Buffer.from(
|
||||
config.client_secret.substring(8),
|
||||
"base64",
|
||||
).toString("utf8");
|
||||
}
|
||||
}
|
||||
|
||||
const tokenResponse = await fetch(config.token_url, {
|
||||
@@ -1150,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<string, unknown>).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(
|
||||
@@ -1300,6 +1333,25 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
// Sync admin status based on OIDC group membership
|
||||
if (config.admin_group) {
|
||||
const groups = (userInfo.groups || userInfo.roles || []) as string[];
|
||||
const shouldBeAdmin = groups.includes(config.admin_group);
|
||||
if (!!userRecord.isAdmin !== shouldBeAdmin) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({ isAdmin: shouldBeAdmin })
|
||||
.where(eq(users.id, userRecord.id));
|
||||
userRecord.isAdmin = shouldBeAdmin;
|
||||
authLogger.info("OIDC admin status synced", {
|
||||
operation: "oidc_admin_group_sync",
|
||||
userId: userRecord.id,
|
||||
group: config.admin_group,
|
||||
isAdmin: shouldBeAdmin,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type);
|
||||
} catch (setupError) {
|
||||
@@ -1333,6 +1385,8 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
const redirectUrl = new URL(frontendOrigin);
|
||||
redirectUrl.searchParams.set("success", "true");
|
||||
|
||||
const isDesktopCallback = frontendOrigin.startsWith("http://127.0.0.1:");
|
||||
|
||||
const maxAge =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
@@ -1342,6 +1396,11 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
|
||||
res.clearCookie("jwt", authManager.getClearCookieOptions(req));
|
||||
|
||||
if (isDesktopCallback) {
|
||||
redirectUrl.searchParams.set("token", token);
|
||||
return res.redirect(redirectUrl.toString());
|
||||
}
|
||||
|
||||
return res
|
||||
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
|
||||
.redirect(redirectUrl.toString());
|
||||
@@ -1883,6 +1942,56 @@ 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
|
||||
* /users/password-login-allowed:
|
||||
@@ -1954,6 +2063,8 @@ router.patch("/password-login-allowed", authenticateJWT, async (req, res) => {
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('allow_password_login', ?)",
|
||||
)
|
||||
.run(allowed ? "true" : "false");
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
res.json({ allowed });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to set password login allowed", err);
|
||||
|
||||
@@ -75,6 +75,7 @@ const clientOptions = {
|
||||
vnc: {
|
||||
"swap-red-blue": false,
|
||||
cursor: "remote",
|
||||
security: "any",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
},
|
||||
|
||||
+214
-23
@@ -7,6 +7,8 @@ import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts } from "../database/db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Client } from "ssh2";
|
||||
import net from "net";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
|
||||
const router = express.Router();
|
||||
@@ -16,18 +18,52 @@ const authManager = AuthManager.getInstance();
|
||||
router.use(authManager.createAuthMiddleware());
|
||||
|
||||
/**
|
||||
* POST /guacamole/token
|
||||
* Generate an encrypted connection token for guacamole-lite
|
||||
*
|
||||
* Body: {
|
||||
* type: "rdp" | "vnc" | "telnet",
|
||||
* hostname: string,
|
||||
* port?: number,
|
||||
* username?: string,
|
||||
* password?: string,
|
||||
* domain?: string,
|
||||
* // Additional protocol-specific options
|
||||
* }
|
||||
* @openapi
|
||||
* /guacamole/token:
|
||||
* post:
|
||||
* summary: Generate an encrypted Guacamole connection token
|
||||
* description: Creates an AES-256-CBC encrypted token for guacamole-lite with the given connection parameters
|
||||
* tags:
|
||||
* - Guacamole
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - type
|
||||
* - hostname
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* enum: [rdp, vnc, telnet]
|
||||
* hostname:
|
||||
* type: string
|
||||
* port:
|
||||
* type: integer
|
||||
* username:
|
||||
* type: string
|
||||
* password:
|
||||
* type: string
|
||||
* domain:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Encrypted connection token
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* token:
|
||||
* type: string
|
||||
* 400:
|
||||
* description: Invalid request
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.post("/token", async (req, res) => {
|
||||
try {
|
||||
@@ -108,6 +144,17 @@ router.post("/token", async (req, res) => {
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Host ID to connect to
|
||||
* requestBody:
|
||||
* required: false
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* protocol:
|
||||
* type: string
|
||||
* enum: [rdp, vnc, telnet]
|
||||
* description: Override the host's default connection type
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Connection token generated successfully
|
||||
@@ -169,13 +216,27 @@ router.post(
|
||||
}
|
||||
}
|
||||
|
||||
const connectionType = (host.connectionType as string) || "ssh";
|
||||
const requestedProtocol = req.body?.protocol as string | undefined;
|
||||
const connectionType =
|
||||
requestedProtocol || (host.connectionType as string);
|
||||
|
||||
if (!["rdp", "vnc", "telnet"].includes(connectionType)) {
|
||||
return res.status(400).json({
|
||||
error: `Connection type '${connectionType}' is not supported for remote desktop. Only RDP, VNC, and Telnet are supported.`,
|
||||
});
|
||||
}
|
||||
|
||||
const protocolEnabledMap: Record<string, boolean> = {
|
||||
rdp: !!host.enableRdp,
|
||||
vnc: !!host.enableVnc,
|
||||
telnet: !!host.enableTelnet,
|
||||
};
|
||||
if (!protocolEnabledMap[connectionType]) {
|
||||
return res.status(400).json({
|
||||
error: `${connectionType.toUpperCase()} is not enabled for this host.`,
|
||||
});
|
||||
}
|
||||
|
||||
let guacConfig: Record<string, unknown> = {};
|
||||
if (host.guacamoleConfig) {
|
||||
try {
|
||||
@@ -192,20 +253,149 @@ router.post(
|
||||
}
|
||||
}
|
||||
|
||||
if (guacConfig.dpi != null) {
|
||||
const parsed = parseInt(String(guacConfig.dpi), 10);
|
||||
guacConfig.dpi =
|
||||
Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
let token: string;
|
||||
const hostname = host.ip as string;
|
||||
const port = host.port as number;
|
||||
const username = (host.username as string) || "";
|
||||
const password = (host.password as string) || "";
|
||||
const domain = (host.domain as string) || "";
|
||||
let hostname = host.ip as string;
|
||||
let port = host.port as number;
|
||||
let username: string;
|
||||
let password: string;
|
||||
|
||||
switch (connectionType) {
|
||||
case "rdp":
|
||||
username =
|
||||
(host.rdpUser as string) || (host.username as string) || "";
|
||||
password =
|
||||
(host.rdpPassword as string) || (host.password as string) || "";
|
||||
port = (host.rdpPort as number) || port || 3389;
|
||||
break;
|
||||
case "vnc":
|
||||
username = (host.vncUser as string) || "";
|
||||
password =
|
||||
(host.vncPassword as string) || (host.password as string) || "";
|
||||
port = (host.vncPort as number) || port || 5900;
|
||||
break;
|
||||
case "telnet":
|
||||
username = (host.telnetUser as string) || "";
|
||||
password =
|
||||
(host.telnetPassword as string) || (host.password as string) || "";
|
||||
port = (host.telnetPort as number) || port || 23;
|
||||
break;
|
||||
default:
|
||||
username = "";
|
||||
password = "";
|
||||
}
|
||||
const domain =
|
||||
(host.rdpDomain as string) || (host.domain as string) || "";
|
||||
|
||||
// Establish SSH tunnel if jump hosts are configured
|
||||
let jumpHosts: Array<{ hostId: number }> = [];
|
||||
if (host.jumpHosts) {
|
||||
try {
|
||||
jumpHosts =
|
||||
typeof host.jumpHosts === "string"
|
||||
? JSON.parse(host.jumpHosts as string)
|
||||
: (host.jumpHosts as Array<{ hostId: number }>);
|
||||
} catch {
|
||||
jumpHosts = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (jumpHosts.length > 0) {
|
||||
try {
|
||||
const { resolveHostById } = await import("../ssh/host-resolver.js");
|
||||
const jumpHost = await resolveHostById(jumpHosts[0].hostId, userId);
|
||||
if (jumpHost) {
|
||||
const tunnelPort = await new Promise<number>((resolve, reject) => {
|
||||
const sshClient = new Client();
|
||||
sshClient.on("ready", () => {
|
||||
const server = net.createServer((sock) => {
|
||||
sshClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
hostname,
|
||||
port,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
sock.destroy();
|
||||
return;
|
||||
}
|
||||
sock.pipe(stream).pipe(sock);
|
||||
},
|
||||
);
|
||||
});
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as net.AddressInfo;
|
||||
// Auto-cleanup after 1 hour
|
||||
setTimeout(
|
||||
() => {
|
||||
server.close();
|
||||
sshClient.end();
|
||||
},
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
resolve(addr.port);
|
||||
});
|
||||
});
|
||||
sshClient.on("error", reject);
|
||||
|
||||
const connectOpts: Record<string, unknown> = {
|
||||
host: jumpHost.ip,
|
||||
port: jumpHost.port || 22,
|
||||
username: jumpHost.username,
|
||||
readyTimeout: 30000,
|
||||
};
|
||||
if (jumpHost.key) {
|
||||
connectOpts.privateKey = jumpHost.key;
|
||||
if (jumpHost.keyPassword)
|
||||
connectOpts.passphrase = jumpHost.keyPassword;
|
||||
} else if (jumpHost.password) {
|
||||
connectOpts.password = jumpHost.password;
|
||||
}
|
||||
sshClient.connect(connectOpts);
|
||||
});
|
||||
hostname = "127.0.0.1";
|
||||
port = tunnelPort;
|
||||
guacLogger.info("SSH tunnel established for guacamole", {
|
||||
operation: "guac_ssh_tunnel",
|
||||
hostId,
|
||||
tunnelPort,
|
||||
});
|
||||
}
|
||||
} catch (tunnelError) {
|
||||
guacLogger.error("Failed to establish SSH tunnel", tunnelError, {
|
||||
operation: "guac_ssh_tunnel_error",
|
||||
hostId,
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: "Failed to establish SSH tunnel to remote host",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
switch (connectionType) {
|
||||
case "rdp":
|
||||
if (guacConfig["enable-drive"] && !guacConfig["drive-path"]) {
|
||||
guacConfig["drive-path"] = "/drive";
|
||||
guacConfig["create-drive-path"] = true;
|
||||
}
|
||||
token = tokenService.createRdpToken(hostname, username, password, {
|
||||
port: port || 3389,
|
||||
port,
|
||||
domain,
|
||||
security: (host.security as string) || undefined,
|
||||
"ignore-cert": (host.ignoreCert as boolean) || false,
|
||||
security:
|
||||
(host.rdpSecurity as string) ||
|
||||
(host.security as string) ||
|
||||
undefined,
|
||||
"ignore-cert":
|
||||
host.rdpIgnoreCert !== undefined
|
||||
? !!host.rdpIgnoreCert
|
||||
: host.ignoreCert !== undefined
|
||||
? !!host.ignoreCert
|
||||
: true,
|
||||
...guacConfig,
|
||||
});
|
||||
break;
|
||||
@@ -215,14 +405,15 @@ router.post(
|
||||
username || undefined,
|
||||
password,
|
||||
{
|
||||
port: port || 5900,
|
||||
port,
|
||||
security: "any",
|
||||
...guacConfig,
|
||||
},
|
||||
);
|
||||
break;
|
||||
case "telnet":
|
||||
token = tokenService.createTelnetToken(hostname, username, password, {
|
||||
port: port || 23,
|
||||
port,
|
||||
...guacConfig,
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -10,6 +10,7 @@ interface ResolvedCredentials {
|
||||
key?: Buffer;
|
||||
keyPassword?: string;
|
||||
authType?: string;
|
||||
certPublicKey?: string;
|
||||
}
|
||||
|
||||
interface HostConfig {
|
||||
@@ -76,11 +77,13 @@ export class SSHAuthManager {
|
||||
resolvedCredentials = {
|
||||
username: (cred.username as string) || hostConfig.username,
|
||||
password: (cred.password as string) || undefined,
|
||||
key: cred.privateKey
|
||||
? Buffer.from(cred.privateKey as string)
|
||||
: undefined,
|
||||
key:
|
||||
cred.key || cred.privateKey
|
||||
? Buffer.from((cred.key || cred.privateKey) as string)
|
||||
: undefined,
|
||||
keyPassword: (cred.keyPassword as string) || undefined,
|
||||
authType: (cred.authType as string) || "none",
|
||||
certPublicKey: (cred.certPublicKey as string) || undefined,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -24,39 +24,6 @@ const activeSessions = new Map<string, SSHSession>();
|
||||
const wss = new WebSocketServer({
|
||||
host: "0.0.0.0",
|
||||
port: 30009,
|
||||
verifyClient: async (info) => {
|
||||
try {
|
||||
let token: string | undefined;
|
||||
|
||||
const cookieHeader = info.req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) token = decodeURIComponent(match[1]);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const authHeader = info.req.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
token = authHeader.slice("Bearer ".length);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const decoded = await authManager.verifyJWTToken(token);
|
||||
|
||||
if (!decoded || !decoded.userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function detectShell(
|
||||
@@ -173,7 +140,9 @@ async function createJumpHostChain(
|
||||
const credential = credentials[0];
|
||||
resolvedCredentials = {
|
||||
password: credential.password as string | undefined,
|
||||
sshKey: credential.privateKey as string | undefined,
|
||||
sshKey: (credential.key || credential.privateKey) as
|
||||
| string
|
||||
| undefined,
|
||||
keyPassword: credential.keyPassword as string | undefined,
|
||||
authType: credential.authType as string | undefined,
|
||||
};
|
||||
@@ -257,6 +226,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const urlObj = new URL(req.url || "", "http://localhost");
|
||||
const qp = urlObj.searchParams.get("token");
|
||||
if (qp) token = qp;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
|
||||
@@ -139,10 +139,7 @@ async function resolveJumpHost(
|
||||
): Promise<JumpHostConfig | null> {
|
||||
try {
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
|
||||
getDb().select().from(hosts).where(eq(hosts.id, hostId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
@@ -152,8 +149,37 @@ async function resolveJumpHost(
|
||||
}
|
||||
|
||||
const host = hostResults[0];
|
||||
const ownerId = (host.userId || userId) as string;
|
||||
|
||||
if (host.credentialId) {
|
||||
if (userId !== ownerId) {
|
||||
try {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
if (sharedCred) {
|
||||
return {
|
||||
...host,
|
||||
password: sharedCred.password,
|
||||
key: sharedCred.key,
|
||||
keyPassword: sharedCred.keyPassword,
|
||||
keyType: sharedCred.keyType,
|
||||
authType: sharedCred.key
|
||||
? "key"
|
||||
: sharedCred.password
|
||||
? "password"
|
||||
: "none",
|
||||
} as JumpHostConfig;
|
||||
}
|
||||
} catch {
|
||||
// fall through to owner credential lookup
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
@@ -161,11 +187,11 @@ async function resolveJumpHost(
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId as number),
|
||||
eq(sshCredentials.userId, userId),
|
||||
eq(sshCredentials.userId, ownerId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
ownerId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
@@ -173,7 +199,7 @@ async function resolveJumpHost(
|
||||
return {
|
||||
...host,
|
||||
password: credential.password as string | undefined,
|
||||
key: credential.privateKey as string | undefined,
|
||||
key: (credential.key || credential.privateKey) as string | undefined,
|
||||
keyPassword: credential.keyPassword as string | undefined,
|
||||
keyType: credential.keyType as string | undefined,
|
||||
authType: credential.authType as string | undefined,
|
||||
@@ -714,7 +740,9 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
const credential = credentials[0];
|
||||
resolvedCredentials = {
|
||||
password: credential.password as string | undefined,
|
||||
sshKey: credential.privateKey as string | undefined,
|
||||
sshKey: (credential.key || credential.privateKey) as
|
||||
| string
|
||||
| undefined,
|
||||
keyPassword: credential.keyPassword as string | undefined,
|
||||
authType: credential.authType as string | undefined,
|
||||
};
|
||||
@@ -2970,7 +2998,7 @@ app.get("/docker/containers/:sessionId/:containerId/logs", async (req, res) => {
|
||||
session.activeOperations++;
|
||||
|
||||
try {
|
||||
let command = `docker logs ${containerId}`;
|
||||
let command = `docker logs ${containerId} 2>&1`;
|
||||
|
||||
if (tail && tail > 0) {
|
||||
command += ` --tail ${Math.floor(tail)}`;
|
||||
|
||||
+199
-13
@@ -151,10 +151,7 @@ async function resolveJumpHost(
|
||||
): Promise<JumpHostConfig | null> {
|
||||
try {
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
|
||||
getDb().select().from(hosts).where(eq(hosts.id, hostId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
@@ -164,8 +161,37 @@ async function resolveJumpHost(
|
||||
}
|
||||
|
||||
const host = hostResults[0];
|
||||
const ownerId = (host.userId || userId) as string;
|
||||
|
||||
if (host.credentialId) {
|
||||
if (userId !== ownerId) {
|
||||
try {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
if (sharedCred) {
|
||||
return {
|
||||
...host,
|
||||
password: sharedCred.password,
|
||||
key: sharedCred.key,
|
||||
keyPassword: sharedCred.keyPassword,
|
||||
keyType: sharedCred.keyType,
|
||||
authType: sharedCred.key
|
||||
? "key"
|
||||
: sharedCred.password
|
||||
? "password"
|
||||
: "none",
|
||||
} as JumpHostConfig;
|
||||
}
|
||||
} catch {
|
||||
// fall through to owner credential lookup
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
@@ -173,11 +199,11 @@ async function resolveJumpHost(
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId as number),
|
||||
eq(sshCredentials.userId, userId),
|
||||
eq(sshCredentials.userId, ownerId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
ownerId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
@@ -185,7 +211,7 @@ async function resolveJumpHost(
|
||||
return {
|
||||
...host,
|
||||
password: credential.password as string | undefined,
|
||||
key: credential.privateKey as string | undefined,
|
||||
key: (credential.key || credential.privateKey) as string | undefined,
|
||||
keyPassword: credential.keyPassword as string | undefined,
|
||||
keyType: credential.keyType as string | undefined,
|
||||
authType: credential.authType as string | undefined,
|
||||
@@ -428,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) => {
|
||||
@@ -450,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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -862,7 +910,13 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
);
|
||||
|
||||
// Resolve credentials server-side when frontend doesn't provide them
|
||||
let resolvedCredentials = { password, sshKey, keyPassword, authType };
|
||||
let resolvedCredentials = {
|
||||
password,
|
||||
sshKey,
|
||||
keyPassword,
|
||||
authType,
|
||||
sudoPassword: undefined as string | undefined,
|
||||
};
|
||||
if (hostId && userId && !password && !sshKey) {
|
||||
try {
|
||||
const { resolveHostById } = await import("./host-resolver.js");
|
||||
@@ -873,6 +927,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
sshKey: resolvedHost.key,
|
||||
keyPassword: resolvedHost.keyPassword,
|
||||
authType: resolvedHost.authType,
|
||||
sudoPassword: resolvedHost.sudoPassword as string | undefined,
|
||||
};
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
@@ -900,6 +955,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
sshKey: resolvedHost.key,
|
||||
keyPassword: resolvedHost.keyPassword,
|
||||
authType: resolvedHost.authType,
|
||||
sudoPassword: resolvedHost.sudoPassword as string | undefined,
|
||||
};
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
@@ -1194,6 +1250,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
activeOperations: 0,
|
||||
channelOpener: new ChannelOpenSerializer(),
|
||||
userId,
|
||||
sudoPassword: resolvedCredentials.sudoPassword,
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
res.json({
|
||||
@@ -3058,6 +3115,42 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
||||
|
||||
stream.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
const isPermissionDenied = errorData
|
||||
.toLowerCase()
|
||||
.includes("permission denied");
|
||||
|
||||
if (isPermissionDenied && sshConn.sudoPassword) {
|
||||
execWithSudoBuffer(
|
||||
sshConn,
|
||||
`cat '${escapedPath}'`,
|
||||
sshConn.sudoPassword,
|
||||
)
|
||||
.then((result) => {
|
||||
if (result.code !== 0) {
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: ${result.stderr || result.stdout.toString("utf8")}`,
|
||||
needsSudo: true,
|
||||
});
|
||||
}
|
||||
|
||||
const sudoData = result.stdout;
|
||||
const isBinary = detectBinary(sudoData);
|
||||
res.json({
|
||||
content: isBinary
|
||||
? sudoData.toString("base64")
|
||||
: sudoData.toString("utf8"),
|
||||
isBinary,
|
||||
size: sudoData.length,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
res
|
||||
.status(403)
|
||||
.json({ error: "Permission denied", needsSudo: true });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
fileLogger.error(
|
||||
`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
);
|
||||
@@ -3490,12 +3583,47 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const isPermDenied = errorData
|
||||
.toLowerCase()
|
||||
.includes("permission denied");
|
||||
if (isPermDenied && sshConn.sudoPassword) {
|
||||
execWithSudo(
|
||||
sshConn,
|
||||
`bash -c "echo '${base64Content}' | base64 -d > '${escapedPath}' && echo SUCCESS"`,
|
||||
sshConn.sudoPassword,
|
||||
)
|
||||
.then(({ stdout, code: sudoCode }) => {
|
||||
if (sudoCode === 0 && stdout.includes("SUCCESS")) {
|
||||
restoreOriginalMode(null, () => {
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "File written successfully",
|
||||
path: filePath,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (!res.headersSent) {
|
||||
res
|
||||
.status(403)
|
||||
.json({ error: "Permission denied", needsSudo: true });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!res.headersSent) {
|
||||
res
|
||||
.status(403)
|
||||
.json({ error: "Permission denied", needsSudo: true });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
fileLogger.error(
|
||||
`Fallback write failed with code ${code}: ${errorData}`,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: `Write failed: ${errorData}`,
|
||||
needsSudo: isPermDenied,
|
||||
toast: { type: "error", message: `Write failed: ${errorData}` },
|
||||
});
|
||||
}
|
||||
@@ -4870,6 +4998,64 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/ssh/file_manager/ssh/downloadFileStream", async (req, res) => {
|
||||
const { sessionId, path: filePath } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId || !filePath) {
|
||||
return res.status(400).json({ error: "Missing download parameters" });
|
||||
}
|
||||
|
||||
const sshConn = sshSessions[sessionId];
|
||||
if (!sshConn?.isConnected) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "SSH session not found or not connected" });
|
||||
}
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
try {
|
||||
const sftp = await getSessionSftp(sshConn);
|
||||
const stats = await new Promise<{ size: number; isFile: () => boolean }>(
|
||||
(resolve, reject) => {
|
||||
sftp.stat(filePath, (err, s) => (err ? reject(err) : resolve(s)));
|
||||
},
|
||||
);
|
||||
|
||||
if (!stats.isFile()) {
|
||||
return res.status(400).json({ error: "Cannot download directories" });
|
||||
}
|
||||
|
||||
const fileName = filePath.split("/").pop() || "download";
|
||||
res.setHeader("Content-Type", "application/octet-stream");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${encodeURIComponent(fileName)}"`,
|
||||
);
|
||||
res.setHeader("Content-Length", String(stats.size));
|
||||
|
||||
const readStream = sftp.createReadStream(filePath);
|
||||
readStream.on("error", (err) => {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Download failed: ${err.message}` });
|
||||
} else {
|
||||
res.destroy();
|
||||
}
|
||||
});
|
||||
readStream.pipe(res);
|
||||
} catch (err) {
|
||||
if (!res.headersSent) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Download failed: ${(err as Error).message}` });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/copyItem:
|
||||
|
||||
@@ -75,8 +75,55 @@ export async function resolveHostById(
|
||||
if (host.credentialId) {
|
||||
const ownerId = (host.userId || userId) as string;
|
||||
try {
|
||||
// Try shared credential first for non-owner users
|
||||
// Try user's own override credential first
|
||||
if (userId !== ownerId) {
|
||||
try {
|
||||
const { hostAccess } = await import("../database/db/schema.js");
|
||||
const accessRecords = await db
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.where(
|
||||
and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
const overrideCredId = accessRecords[0]?.overrideCredentialId as
|
||||
| number
|
||||
| null;
|
||||
if (overrideCredId) {
|
||||
const userCreds = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, overrideCredId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
if (userCreds.length > 0) {
|
||||
const cred = userCreds[0] as Record<string, unknown>;
|
||||
host.password = cred.password;
|
||||
host.key = cred.key;
|
||||
host.keyPassword = cred.keyPassword;
|
||||
host.keyType = cred.keyType;
|
||||
if (!host.overrideCredentialUsername) {
|
||||
host.username = cred.username;
|
||||
}
|
||||
host.authType = cred.key
|
||||
? "key"
|
||||
: cred.password
|
||||
? "password"
|
||||
: "none";
|
||||
return host as unknown as SSHHost;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through to shared credential
|
||||
}
|
||||
|
||||
try {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../utils/shared-credential-manager.js");
|
||||
@@ -126,13 +173,17 @@ export async function resolveHostById(
|
||||
if (credentials.length > 0) {
|
||||
const cred = credentials[0] as Record<string, unknown>;
|
||||
host.password = cred.password;
|
||||
host.key = cred.key;
|
||||
// Prefer the normalised private key; fall back to raw key field
|
||||
host.key = (cred.privateKey || cred.key) as string | null;
|
||||
host.keyPassword = cred.keyPassword;
|
||||
host.keyType = cred.keyType;
|
||||
// CA-signed certificate for cert-based auth
|
||||
(host as Record<string, unknown>).certPublicKey =
|
||||
cred.certPublicKey || null;
|
||||
if (!host.overrideCredentialUsername) {
|
||||
host.username = cred.username;
|
||||
}
|
||||
host.authType = cred.key ? "key" : cred.password ? "password" : "none";
|
||||
host.authType = host.key ? "key" : host.password ? "password" : "none";
|
||||
}
|
||||
} catch (e) {
|
||||
sshLogger.warn("Failed to resolve credential for host", {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// OPKSSH certificate authentication workarounds for ssh2.
|
||||
// SSH certificate authentication workarounds for ssh2.
|
||||
// ssh2 doesn't support OpenSSH cert auth natively — this module grafts
|
||||
// the certificate onto the parsed key, wraps ECDSA signing to convert
|
||||
// DER → SSH wire format, and patches Protocol.authPK to use the base
|
||||
// algorithm in the signature wrapper (required by OpenSSH's sshkey_check_sigtype).
|
||||
//
|
||||
// setupOPKSSHCertAuth: for OPKSSH-issued ephemeral certificates (no passphrase)
|
||||
// setupCACertAuth: for user-managed CA-signed -cert.pub files (passphrase supported)
|
||||
|
||||
import type {
|
||||
AnyAuthMethod,
|
||||
@@ -62,44 +65,39 @@ type OPKSSHNextAuthHandler = (
|
||||
authInfo: AuthenticationType | AnyAuthMethod | false,
|
||||
) => void;
|
||||
|
||||
export async function setupOPKSSHCertAuth(
|
||||
// ── Internal implementation ──────────────────────────────────────────────────
|
||||
// Grafts an OpenSSH certificate onto an already-parsed private key object and
|
||||
// patches the ssh2 client so that certificate-based publickey auth succeeds.
|
||||
|
||||
async function _applyCertToConnection(
|
||||
config: ConnectConfig,
|
||||
client: Client,
|
||||
token: OPKSSHToken,
|
||||
username: string,
|
||||
privKey: ParsedPrivateKey,
|
||||
certStr: string,
|
||||
): Promise<void> {
|
||||
const { createRequire } = await import("node:module");
|
||||
const esmRequire = createRequire(import.meta.url);
|
||||
const {
|
||||
utils: { parseKey },
|
||||
} = esmRequire("ssh2");
|
||||
|
||||
const parsed = parseKey(Buffer.from(token.privateKey));
|
||||
if (parsed instanceof Error || !parsed) {
|
||||
throw new Error("Failed to parse OPKSSH private key");
|
||||
}
|
||||
const privKey = (
|
||||
Array.isArray(parsed) ? parsed[0] : parsed
|
||||
) as ParsedPrivateKey;
|
||||
|
||||
// Extract cert type and blob from the stored certificate
|
||||
const certParts = token.sshCert.trim().split(/\s+/);
|
||||
const certParts = certStr.trim().split(/\s+/);
|
||||
if (certParts.length < 2) {
|
||||
throw new Error(
|
||||
"Invalid certificate format: expected '<type> <base64>' string",
|
||||
);
|
||||
}
|
||||
const certType = certParts[0];
|
||||
privKey.type = certType;
|
||||
const certBlob = Buffer.from(certParts[1], "base64");
|
||||
|
||||
// Replace the internal public SSH blob with the full certificate
|
||||
// Graft cert type and blob onto the parsed private key
|
||||
privKey.type = certType;
|
||||
const pubSSHSym = Object.getOwnPropertySymbols(privKey).find(
|
||||
(s) => String(s) === "Symbol(Public key SSH)",
|
||||
);
|
||||
if (!pubSSHSym) {
|
||||
throw new Error(
|
||||
"Cannot find public SSH symbol on parsed key, ssh2 internals may have changed",
|
||||
"Cannot find public SSH symbol on parsed key; ssh2 internals may have changed",
|
||||
);
|
||||
}
|
||||
privKey[pubSSHSym] = certBlob;
|
||||
|
||||
// Wrap sign() for ECDSA cert keys
|
||||
// Wrap sign() for ECDSA cert keys (DER → SSH wire format)
|
||||
if (privKey.type.startsWith("ecdsa-")) {
|
||||
const origSign = privKey.sign.bind(privKey);
|
||||
privKey.sign = (data: Buffer, algo?: string) => {
|
||||
@@ -145,7 +143,7 @@ export async function setupOPKSSHCertAuth(
|
||||
certAuthAttempted = true;
|
||||
next({
|
||||
type: "publickey",
|
||||
username,
|
||||
username: (config as Record<string, unknown>).username as string,
|
||||
key: privKey as unknown as PublicKeyAuthMethod["key"],
|
||||
});
|
||||
} else {
|
||||
@@ -296,3 +294,75 @@ export async function setupOPKSSHCertAuth(
|
||||
return connectedClient;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Set up OPKSSH certificate authentication on an ssh2 Client.
|
||||
* The OPKSSH private key is assumed to be unencrypted (no passphrase).
|
||||
*/
|
||||
export async function setupOPKSSHCertAuth(
|
||||
config: ConnectConfig,
|
||||
client: Client,
|
||||
token: OPKSSHToken,
|
||||
username: string,
|
||||
): Promise<void> {
|
||||
const { createRequire } = await import("node:module");
|
||||
const esmRequire = createRequire(import.meta.url);
|
||||
const {
|
||||
utils: { parseKey },
|
||||
} = esmRequire("ssh2");
|
||||
|
||||
// Store username in config so the authHandler can access it
|
||||
(config as Record<string, unknown>).username = username;
|
||||
|
||||
const parsed = parseKey(Buffer.from(token.privateKey));
|
||||
if (parsed instanceof Error || !parsed) {
|
||||
throw new Error("Failed to parse OPKSSH private key");
|
||||
}
|
||||
const privKey = (
|
||||
Array.isArray(parsed) ? parsed[0] : parsed
|
||||
) as ParsedPrivateKey;
|
||||
|
||||
await _applyCertToConnection(config, client, privKey, token.sshCert);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up CA-signed certificate authentication on an ssh2 Client.
|
||||
* Supports passphrase-protected private keys.
|
||||
* The cert content is the full text of the -cert.pub file
|
||||
* (e.g. "ssh-ed25519-cert-v01@openssh.com AAAA...").
|
||||
*/
|
||||
export async function setupCACertAuth(
|
||||
config: ConnectConfig,
|
||||
client: Client,
|
||||
privateKey: Buffer | string,
|
||||
certPublicKey: string,
|
||||
username: string,
|
||||
passphrase?: string,
|
||||
): Promise<void> {
|
||||
const { createRequire } = await import("node:module");
|
||||
const esmRequire = createRequire(import.meta.url);
|
||||
const {
|
||||
utils: { parseKey },
|
||||
} = esmRequire("ssh2");
|
||||
|
||||
// Store username in config so the authHandler can access it
|
||||
(config as Record<string, unknown>).username = username;
|
||||
|
||||
const keyBuf = Buffer.isBuffer(privateKey)
|
||||
? privateKey
|
||||
: Buffer.from(privateKey, "utf8");
|
||||
|
||||
const parsed = passphrase ? parseKey(keyBuf, passphrase) : parseKey(keyBuf);
|
||||
|
||||
if (parsed instanceof Error || !parsed) {
|
||||
const errMsg = parsed instanceof Error ? parsed.message : "unknown error";
|
||||
throw new Error(`Failed to parse private key for CA cert auth: ${errMsg}`);
|
||||
}
|
||||
const privKey = (
|
||||
Array.isArray(parsed) ? parsed[0] : parsed
|
||||
) as ParsedPrivateKey;
|
||||
|
||||
await _applyCertToConnection(config, client, privKey, certPublicKey);
|
||||
}
|
||||
|
||||
@@ -75,10 +75,7 @@ async function resolveJumpHost(
|
||||
): Promise<JumpHostConfig | null> {
|
||||
try {
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
|
||||
getDb().select().from(hosts).where(eq(hosts.id, hostId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
@@ -88,8 +85,37 @@ async function resolveJumpHost(
|
||||
}
|
||||
|
||||
const host = hostResults[0];
|
||||
const ownerId = (host.userId || userId) as string;
|
||||
|
||||
if (host.credentialId) {
|
||||
if (userId !== ownerId) {
|
||||
try {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
if (sharedCred) {
|
||||
return {
|
||||
...host,
|
||||
password: sharedCred.password,
|
||||
key: sharedCred.key,
|
||||
keyPassword: sharedCred.keyPassword,
|
||||
keyType: sharedCred.keyType,
|
||||
authType: sharedCred.key
|
||||
? "key"
|
||||
: sharedCred.password
|
||||
? "password"
|
||||
: "none",
|
||||
} as JumpHostConfig;
|
||||
}
|
||||
} catch {
|
||||
// fall through to owner credential lookup
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
@@ -97,11 +123,11 @@ async function resolveJumpHost(
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId as number),
|
||||
eq(sshCredentials.userId, userId),
|
||||
eq(sshCredentials.userId, ownerId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
ownerId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
@@ -109,7 +135,7 @@ async function resolveJumpHost(
|
||||
return {
|
||||
...host,
|
||||
password: credential.password as string | undefined,
|
||||
key: credential.privateKey as string | undefined,
|
||||
key: (credential.key || credential.privateKey) as string | undefined,
|
||||
keyPassword: credential.keyPassword as string | undefined,
|
||||
keyType: credential.keyType as string | undefined,
|
||||
authType: credential.authType as string | undefined,
|
||||
|
||||
+272
-102
@@ -1,5 +1,14 @@
|
||||
import { WebSocketServer, WebSocket, type RawData } from "ws";
|
||||
import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
|
||||
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";
|
||||
@@ -22,9 +31,44 @@ import { sessionManager } from "./terminal-session-manager.js";
|
||||
import {
|
||||
detectTmux,
|
||||
attachOrCreateTmuxSession,
|
||||
queryNewestTmuxSession,
|
||||
waitForTmuxSession,
|
||||
} from "./tmux-helper.js";
|
||||
|
||||
class MemoryAgent extends BaseAgent {
|
||||
private key: ParsedKey;
|
||||
|
||||
constructor(key: ParsedKey) {
|
||||
super();
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
getIdentities(cb: IdentityCallback<ParsedKey>): void {
|
||||
cb(null, [this.key]);
|
||||
}
|
||||
|
||||
sign(
|
||||
pubKey: ParsedKey | Buffer | string,
|
||||
data: Buffer,
|
||||
optionsOrCb: SigningRequestOptions | SignCallback,
|
||||
cb?: SignCallback,
|
||||
): void {
|
||||
const callback = typeof optionsOrCb === "function" ? optionsOrCb : cb!;
|
||||
const options = typeof optionsOrCb === "function" ? {} : optionsOrCb;
|
||||
try {
|
||||
const algo =
|
||||
options.hash === "sha256"
|
||||
? "rsa-sha2-256"
|
||||
: options.hash === "sha512"
|
||||
? "rsa-sha2-512"
|
||||
: undefined;
|
||||
const signature = this.key.sign(data, algo);
|
||||
callback(null, signature);
|
||||
} catch (err) {
|
||||
callback(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function performPortKnocking(
|
||||
host: string,
|
||||
sequence: Array<{ port: number; protocol?: string; delay?: number }>,
|
||||
@@ -145,10 +189,7 @@ async function resolveJumpHost(
|
||||
});
|
||||
try {
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
|
||||
getDb().select().from(hosts).where(eq(hosts.id, hostId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
@@ -158,8 +199,37 @@ async function resolveJumpHost(
|
||||
}
|
||||
|
||||
const host = hostResults[0];
|
||||
const ownerId = (host.userId || userId) as string;
|
||||
|
||||
if (host.credentialId) {
|
||||
if (userId !== ownerId) {
|
||||
try {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
if (sharedCred) {
|
||||
return {
|
||||
...host,
|
||||
password: sharedCred.password,
|
||||
key: sharedCred.key,
|
||||
keyPassword: sharedCred.keyPassword,
|
||||
keyType: sharedCred.keyType,
|
||||
authType: sharedCred.key
|
||||
? "key"
|
||||
: sharedCred.password
|
||||
? "password"
|
||||
: "none",
|
||||
} as JumpHostConfig;
|
||||
}
|
||||
} catch {
|
||||
// fall through to owner credential lookup
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
@@ -167,11 +237,11 @@ async function resolveJumpHost(
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId as number),
|
||||
eq(sshCredentials.userId, userId),
|
||||
eq(sshCredentials.userId, ownerId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
ownerId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
@@ -179,7 +249,7 @@ async function resolveJumpHost(
|
||||
return {
|
||||
...host,
|
||||
password: credential.password as string | undefined,
|
||||
key: credential.privateKey as string | undefined,
|
||||
key: (credential.key || credential.privateKey) as string | undefined,
|
||||
keyPassword: credential.keyPassword as string | undefined,
|
||||
keyType: credential.keyType as string | undefined,
|
||||
authType: credential.authType as string | undefined,
|
||||
@@ -202,13 +272,13 @@ async function createJumpHostChain(
|
||||
jumpHosts: Array<{ hostId: number }>,
|
||||
userId: string,
|
||||
socks5Config?: SOCKS5Config | null,
|
||||
): Promise<Client | null> {
|
||||
): Promise<SSHClientType | null> {
|
||||
if (!jumpHosts || jumpHosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentClient: Client | null = null;
|
||||
const clients: Client[] = [];
|
||||
let currentClient: SSHClientType | null = null;
|
||||
const clients: SSHClientType[] = [];
|
||||
|
||||
try {
|
||||
const jumpHostConfigs = await Promise.all(
|
||||
@@ -364,52 +434,6 @@ async function createJumpHostChain(
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
port: 30002,
|
||||
verifyClient: async (info) => {
|
||||
try {
|
||||
let token: string | undefined;
|
||||
|
||||
const cookieHeader = info.req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) token = decodeURIComponent(match[1]);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const authHeader = info.req.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
token = authHeader.slice("Bearer ".length);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.pendingTOTP) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingConnections = userConnections.get(payload.userId);
|
||||
|
||||
if (existingConnections && existingConnections.size >= 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
sshLogger.error("WebSocket authentication error", error, {
|
||||
operation: "websocket_auth_error",
|
||||
ip: info.req.socket.remoteAddress,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
wss.on("connection", async (ws: WebSocket, req) => {
|
||||
@@ -432,6 +456,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const urlObj = new URL(req.url || "", "http://localhost");
|
||||
const qp = urlObj.searchParams.get("token");
|
||||
if (qp) token = qp;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
@@ -483,9 +513,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
});
|
||||
|
||||
let currentSessionId: string | null = null;
|
||||
let sshConn: Client | null = null;
|
||||
let sshConn: SSHClientType | null = null;
|
||||
let sshStream: ClientChannel | null = null;
|
||||
let lastJumpClient: Client | null = null;
|
||||
let lastJumpClient: SSHClientType | null = null;
|
||||
let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
|
||||
let totpPromptSent = false;
|
||||
let totpTimeout: NodeJS.Timeout | null = null;
|
||||
@@ -806,8 +836,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
: null;
|
||||
if (session?.sshStream) {
|
||||
const existingName = tmuxData.sessionName || undefined;
|
||||
attachOrCreateTmuxSession(session.sshStream, existingName);
|
||||
if (existingName) {
|
||||
attachOrCreateTmuxSession(session.sshStream, existingName);
|
||||
session.tmuxSessionName = existingName;
|
||||
sshLogger.info("User selected tmux session to attach", {
|
||||
operation: "tmux_user_attach",
|
||||
@@ -821,30 +851,51 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// New session from picker -- query name after startup
|
||||
const newName = `termix-${session.hostId}-${Date.now().toString(36).slice(-4)}`;
|
||||
attachOrCreateTmuxSession(session.sshStream, undefined, newName);
|
||||
const sshConn = session.sshConn;
|
||||
setTimeout(async () => {
|
||||
const sessionName = sshConn
|
||||
? await queryNewestTmuxSession(sshConn)
|
||||
: null;
|
||||
session.tmuxSessionName = sessionName;
|
||||
sshLogger.info("User requested new tmux session", {
|
||||
operation: "tmux_user_create",
|
||||
sessionName,
|
||||
hostId: session.hostId,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_session_created",
|
||||
sessionName,
|
||||
}),
|
||||
);
|
||||
}, 500);
|
||||
if (sshConn) {
|
||||
(async () => {
|
||||
const confirmed = await waitForTmuxSession(sshConn, newName);
|
||||
session.tmuxSessionName = confirmed;
|
||||
sshLogger.info("User requested new tmux session", {
|
||||
operation: "tmux_user_create",
|
||||
sessionName: confirmed,
|
||||
hostId: session.hostId,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_session_created",
|
||||
sessionName: confirmed,
|
||||
}),
|
||||
);
|
||||
})();
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "tmux_detach": {
|
||||
const session = currentSessionId
|
||||
? sessionManager.getSession(currentSessionId)
|
||||
: null;
|
||||
if (session?.sshConn && session.tmuxSessionName) {
|
||||
const tmuxName = session.tmuxSessionName;
|
||||
session.sshStream?.write("\x02d");
|
||||
session.tmuxSessionName = null;
|
||||
sshLogger.info("User detached from tmux session", {
|
||||
operation: "tmux_user_detach",
|
||||
sessionName: tmuxName,
|
||||
hostId: session.hostId,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({ type: "tmux_detached", sessionName: tmuxName }),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "totp_response": {
|
||||
const totpData = data as TOTPResponseData;
|
||||
if (keyboardInteractiveFinish && totpData?.code) {
|
||||
@@ -1239,6 +1290,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
keyPassword,
|
||||
keyType,
|
||||
authType,
|
||||
certPublicKey: undefined as string | undefined,
|
||||
};
|
||||
const authMethodNotAvailable = false;
|
||||
if (id && userId && !password && !key) {
|
||||
@@ -1253,6 +1305,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
keyPassword: keyPassword || resolvedHost.keyPassword,
|
||||
keyType: resolvedHost.keyType,
|
||||
authType: resolvedHost.authType,
|
||||
certPublicKey: (resolvedHost as unknown as Record<string, unknown>)
|
||||
.certPublicKey as string | undefined,
|
||||
};
|
||||
sendLog(
|
||||
"auth",
|
||||
@@ -1280,6 +1334,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
keyPassword: keyPassword || resolvedHost.keyPassword,
|
||||
keyType: resolvedHost.keyType,
|
||||
authType: resolvedHost.authType,
|
||||
certPublicKey: (resolvedHost as unknown as Record<string, unknown>)
|
||||
.certPublicKey as string | undefined,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1580,6 +1636,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
JSON.stringify({
|
||||
type: "disconnected",
|
||||
message: "Connection lost",
|
||||
graceful: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1645,31 +1702,27 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
"tmux is not installed on the remote host. Falling back to standard shell.",
|
||||
}),
|
||||
);
|
||||
// tmux unavailable, run commands in plain shell
|
||||
runPostShellCommands(0);
|
||||
} else if (detection.sessions.length === 0) {
|
||||
attachOrCreateTmuxSession(stream);
|
||||
// Query the name tmux assigned after a short delay
|
||||
setTimeout(async () => {
|
||||
const sessionName = await queryNewestTmuxSession(conn);
|
||||
const session = sessionManager.getSession(boundSessionId);
|
||||
if (session) {
|
||||
session.tmuxSessionName = sessionName;
|
||||
}
|
||||
sshLogger.info("Created new tmux session", {
|
||||
operation: "tmux_new_session",
|
||||
sessionName,
|
||||
hostId: id,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_session_created",
|
||||
sessionName,
|
||||
}),
|
||||
);
|
||||
}, 500);
|
||||
// Wait for tmux to start before running commands inside it
|
||||
runPostShellCommands(500);
|
||||
const newName = `termix-${id}-${Date.now().toString(36).slice(-4)}`;
|
||||
attachOrCreateTmuxSession(stream, undefined, newName);
|
||||
const confirmed = await waitForTmuxSession(conn, newName);
|
||||
const session = sessionManager.getSession(boundSessionId);
|
||||
if (session) {
|
||||
session.tmuxSessionName = confirmed;
|
||||
}
|
||||
sshLogger.info("Created new tmux session", {
|
||||
operation: "tmux_new_session",
|
||||
sessionName: confirmed,
|
||||
hostId: id,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_session_created",
|
||||
sessionName: confirmed,
|
||||
}),
|
||||
);
|
||||
runPostShellCommands(0);
|
||||
} else if (detection.sessions.length === 1) {
|
||||
attachOrCreateTmuxSession(stream, detection.sessions[0].name);
|
||||
const sessionName = detection.sessions[0].name;
|
||||
@@ -2233,6 +2286,45 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
if (resolvedCredentials.password) {
|
||||
connectConfig.password = resolvedCredentials.password;
|
||||
}
|
||||
|
||||
// Apply CA-signed certificate if one is stored in the credential
|
||||
if (
|
||||
resolvedCredentials.certPublicKey &&
|
||||
resolvedCredentials.certPublicKey.trim()
|
||||
) {
|
||||
try {
|
||||
const { setupCACertAuth } = await import("./opkssh-cert-auth.js");
|
||||
await setupCACertAuth(
|
||||
connectConfig,
|
||||
sshConn,
|
||||
connectConfig.privateKey as Buffer,
|
||||
resolvedCredentials.certPublicKey,
|
||||
username,
|
||||
resolvedCredentials.keyPassword,
|
||||
);
|
||||
sendLog("auth", "info", "CA certificate authentication configured");
|
||||
sshLogger.info("CA cert auth configured", {
|
||||
operation: "ca_cert_auth_configured",
|
||||
userId,
|
||||
hostId: id,
|
||||
});
|
||||
} catch (certError) {
|
||||
sendLog(
|
||||
"auth",
|
||||
"warning",
|
||||
"CA certificate setup failed – falling back to key-only auth",
|
||||
);
|
||||
sshLogger.warn("CA cert auth setup failed", {
|
||||
operation: "ca_cert_auth_setup_failed",
|
||||
userId,
|
||||
hostId: id,
|
||||
error:
|
||||
certError instanceof Error
|
||||
? certError.message
|
||||
: String(certError),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (keyError) {
|
||||
sshLogger.error("SSH key format error: " + keyError.message);
|
||||
ws.send(
|
||||
@@ -2312,6 +2404,28 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
hostConfig.terminalConfig?.agentForwarding &&
|
||||
connectConfig.privateKey
|
||||
) {
|
||||
try {
|
||||
const parsed = ssh2Utils.parseKey(
|
||||
connectConfig.privateKey as Buffer,
|
||||
connectConfig.passphrase as string | undefined,
|
||||
);
|
||||
if (parsed && !(parsed instanceof Error)) {
|
||||
connectConfig.agent = new MemoryAgent(parsed);
|
||||
connectConfig.agentForward = true;
|
||||
sendLog("auth", "info", "SSH agent forwarding enabled");
|
||||
}
|
||||
} catch {
|
||||
sshLogger.warn("Failed to set up agent forwarding", {
|
||||
operation: "agent_forward_setup",
|
||||
hostId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
hostConfig.portKnockSequence &&
|
||||
hostConfig.portKnockSequence.length > 0
|
||||
@@ -2350,6 +2464,62 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
hostConfig.jumpHosts.length > 0 &&
|
||||
hostConfig.userId;
|
||||
|
||||
// Cloudflare Tunnel: connect via WebSocket proxy
|
||||
const cfConfig = hostConfig.terminalConfig as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (cfConfig?.cfAccessClientId && cfConfig?.cfAccessClientSecret) {
|
||||
try {
|
||||
const WebSocket = (await import("ws")).default;
|
||||
const cfHostname = (cfConfig.cfTunnelHostname as string) || ip;
|
||||
const wsUrl = `wss://${cfHostname}/cdn-cgi/access/ssh-connect`;
|
||||
const cfWs = new WebSocket(wsUrl, {
|
||||
headers: {
|
||||
"CF-Access-Client-Id": cfConfig.cfAccessClientId as string,
|
||||
"CF-Access-Client-Secret": cfConfig.cfAccessClientSecret as string,
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
cfWs.on("open", () => resolve());
|
||||
cfWs.on("error", (err) => reject(err));
|
||||
setTimeout(
|
||||
() => reject(new Error("Cloudflare tunnel timeout")),
|
||||
30000,
|
||||
);
|
||||
});
|
||||
|
||||
const { Duplex } = await import("stream");
|
||||
const duplexStream = new Duplex({
|
||||
read() {},
|
||||
write(chunk, _encoding, callback) {
|
||||
cfWs.send(chunk, callback);
|
||||
},
|
||||
});
|
||||
cfWs.on("message", (data) => duplexStream.push(data));
|
||||
cfWs.on("close", () => duplexStream.push(null));
|
||||
|
||||
connectConfig.sock =
|
||||
duplexStream as unknown as typeof connectConfig.sock;
|
||||
sendLog("handshake", "info", "Connected via Cloudflare Tunnel");
|
||||
} catch (cfError) {
|
||||
sshLogger.error("Cloudflare tunnel connection failed", cfError, {
|
||||
operation: "cf_tunnel_connect",
|
||||
hostId: id,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"Cloudflare tunnel connection failed: " +
|
||||
(cfError instanceof Error ? cfError.message : "Unknown error"),
|
||||
}),
|
||||
);
|
||||
cleanupAuthState(connectionTimeout);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasJumpHosts) {
|
||||
try {
|
||||
const jumpClient = await createJumpHostChain(
|
||||
|
||||
@@ -103,6 +103,7 @@ const TMUX_OPTS =
|
||||
`set -gq mouse on` +
|
||||
` \\; set -gq history-limit 50000` +
|
||||
` \\; set -gq set-clipboard on` +
|
||||
` \\; set -gq aggressive-resize on` +
|
||||
` \\; set -gq mode-keys vi` +
|
||||
` \\; bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X stop-selection` +
|
||||
` \\; bind-key -T copy-mode-vi Enter send-keys -X copy-selection-and-cancel` +
|
||||
@@ -110,6 +111,32 @@ const TMUX_OPTS =
|
||||
` 'if -F "#{pane_in_mode}"` +
|
||||
` "display-message -d 2500 \\"Adjust selection and press Enter to copy\\""'`;
|
||||
|
||||
/**
|
||||
* Wait for a tmux session to appear by polling via exec channel.
|
||||
* Returns the session name once found, or null on timeout.
|
||||
*/
|
||||
export async function waitForTmuxSession(
|
||||
conn: Client,
|
||||
sessionName: string,
|
||||
timeoutMs = 5000,
|
||||
intervalMs = 100,
|
||||
): Promise<string | null> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await execCommand(
|
||||
conn,
|
||||
`tmux has-session -t ${shellEscape(sessionName)} 2>/dev/null`,
|
||||
);
|
||||
return sessionName;
|
||||
} catch {
|
||||
// session not ready yet
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write tmux attach or new-session command to the interactive shell stream.
|
||||
* Uses && exit so the shell only closes if tmux started successfully.
|
||||
@@ -117,12 +144,14 @@ const TMUX_OPTS =
|
||||
export function attachOrCreateTmuxSession(
|
||||
stream: ClientChannel,
|
||||
existingSessionName?: string,
|
||||
newSessionName?: string,
|
||||
): void {
|
||||
let command: string;
|
||||
if (existingSessionName) {
|
||||
command = `tmux ${TMUX_OPTS} \\; attach-session -t ${shellEscape(existingSessionName)} && exit\r`;
|
||||
} else {
|
||||
command = `tmux ${TMUX_OPTS} \\; new-session && exit\r`;
|
||||
const nameFlag = newSessionName ? ` -s ${shellEscape(newSessionName)}` : "";
|
||||
command = `tmux ${TMUX_OPTS} \\; new-session${nameFlag} && exit\r`;
|
||||
}
|
||||
|
||||
sshLogger.info("Writing tmux command to shell", {
|
||||
|
||||
@@ -1674,7 +1674,9 @@ async function connectSSHTunnel(
|
||||
const credential = credentials[0];
|
||||
resolvedEndpointCredentials = {
|
||||
password: credential.password as string | undefined,
|
||||
sshKey: credential.privateKey as string | undefined,
|
||||
sshKey: (credential.key || credential.privateKey) as
|
||||
| string
|
||||
| undefined,
|
||||
keyPassword: credential.keyPassword as string | undefined,
|
||||
keyType: credential.keyType as string | undefined,
|
||||
authMethod: credential.authType as string,
|
||||
@@ -2695,9 +2697,22 @@ app.post(
|
||||
|
||||
res.json({ message: "Connection request received", tunnelName });
|
||||
|
||||
operation.finally(() => {
|
||||
pendingTunnelOperations.delete(tunnelName);
|
||||
});
|
||||
operation
|
||||
.catch((err) => {
|
||||
tunnelLogger.error("Tunnel operation failed", err, {
|
||||
operation: "tunnel_operation_failed",
|
||||
tunnelName,
|
||||
});
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
tunnelConnecting.delete(tunnelName);
|
||||
})
|
||||
.finally(() => {
|
||||
pendingTunnelOperations.delete(tunnelName);
|
||||
});
|
||||
} catch (error) {
|
||||
tunnelLogger.error("Failed to process tunnel connect", error, {
|
||||
operation: "tunnel_connect",
|
||||
|
||||
+22
-19
@@ -137,7 +137,8 @@ import {
|
||||
},
|
||||
);
|
||||
|
||||
await import("./database/database.js");
|
||||
const dbServer = await import("./database/database.js");
|
||||
await (dbServer as unknown as { serverReady: Promise<void> }).serverReady;
|
||||
await import("./ssh/terminal.js");
|
||||
await import("./ssh/tunnel.js");
|
||||
await import("./ssh/file-manager.js");
|
||||
@@ -194,29 +195,31 @@ import {
|
||||
duration: Date.now() - initStartTime,
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
systemLogger.info(
|
||||
"Received SIGINT signal, initiating graceful shutdown...",
|
||||
{ operation: "shutdown" },
|
||||
);
|
||||
const gracefulShutdown = async (signal: string) => {
|
||||
systemLogger.info(`Received ${signal}, initiating graceful shutdown...`, {
|
||||
operation: "shutdown",
|
||||
});
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("./database/db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
systemLogger.info("Database saved to disk before exit", {
|
||||
operation: "shutdown_db_saved",
|
||||
});
|
||||
} catch (error) {
|
||||
systemLogger.error("Failed to save database during shutdown", error, {
|
||||
operation: "shutdown_db_save_failed",
|
||||
});
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
systemLogger.info(
|
||||
"Received SIGTERM signal, initiating graceful shutdown...",
|
||||
{ operation: "shutdown" },
|
||||
);
|
||||
process.exit(0);
|
||||
});
|
||||
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
||||
|
||||
process.on("message", (msg: { type?: string }) => {
|
||||
if (msg?.type === "shutdown") {
|
||||
systemLogger.info(
|
||||
"Received IPC shutdown, initiating graceful shutdown...",
|
||||
{ operation: "shutdown" },
|
||||
);
|
||||
process.exit(0);
|
||||
gracefulShutdown("IPC shutdown");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import swaggerJSDoc from "swagger-jsdoc";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { promises as fs } from "fs";
|
||||
import { systemLogger } from "./utils/logger.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const swaggerOptions: swaggerJSDoc.Options = {
|
||||
definition: {
|
||||
openapi: "3.0.3",
|
||||
info: {
|
||||
title: "Termix API",
|
||||
version: "0.0.0",
|
||||
description: "Termix Backend API Reference",
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: "http://localhost:30001",
|
||||
description: "Main database and authentication server",
|
||||
},
|
||||
{
|
||||
url: "http://localhost:30003",
|
||||
description: "SSH tunnel management server",
|
||||
},
|
||||
{
|
||||
url: "http://localhost:30004",
|
||||
description: "SSH file manager server",
|
||||
},
|
||||
{
|
||||
url: "http://localhost:30005",
|
||||
description: "Server statistics and monitoring server",
|
||||
},
|
||||
{
|
||||
url: "http://localhost:30006",
|
||||
description: "Dashboard server",
|
||||
},
|
||||
{
|
||||
url: "http://localhost:30007",
|
||||
description: "Docker management server",
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "JWT",
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
Error: {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
details: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [
|
||||
{
|
||||
bearerAuth: [],
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: "Alerts",
|
||||
description: "System alerts and notifications management",
|
||||
},
|
||||
{
|
||||
name: "Credentials",
|
||||
description: "SSH credential management",
|
||||
},
|
||||
{
|
||||
name: "Network Topology",
|
||||
description: "Network topology visualization and management",
|
||||
},
|
||||
{
|
||||
name: "RBAC",
|
||||
description: "Role-based access control for host sharing",
|
||||
},
|
||||
{
|
||||
name: "Snippets",
|
||||
description: "Command snippet management",
|
||||
},
|
||||
{
|
||||
name: "Terminal",
|
||||
description: "Terminal command history",
|
||||
},
|
||||
{
|
||||
name: "Users",
|
||||
description: "User management and authentication",
|
||||
},
|
||||
{
|
||||
name: "Dashboard",
|
||||
description: "Dashboard statistics and activity",
|
||||
},
|
||||
{
|
||||
name: "Docker",
|
||||
description: "Docker container management",
|
||||
},
|
||||
{
|
||||
name: "SSH Tunnels",
|
||||
description: "SSH tunnel connection management",
|
||||
},
|
||||
{
|
||||
name: "Server Stats",
|
||||
description: "Server status monitoring and metrics collection",
|
||||
},
|
||||
{
|
||||
name: "File Manager",
|
||||
description: "SSH file management operations",
|
||||
},
|
||||
],
|
||||
},
|
||||
apis: [
|
||||
path.join(__dirname, "database", "routes", "*.js").replace(/\\/g, "/"),
|
||||
path.join(__dirname, "dashboard.js").replace(/\\/g, "/"),
|
||||
path.join(__dirname, "ssh", "*.js").replace(/\\/g, "/"),
|
||||
path.join(__dirname, "guacamole", "routes.js").replace(/\\/g, "/"),
|
||||
],
|
||||
};
|
||||
|
||||
async function generateOpenAPISpec() {
|
||||
try {
|
||||
systemLogger.info("Generating OpenAPI specification", {
|
||||
operation: "openapi_generate_start",
|
||||
});
|
||||
|
||||
const swaggerSpec = swaggerJSDoc(swaggerOptions);
|
||||
|
||||
const outputPath = path.join(__dirname, "..", "..", "..", "openapi.json");
|
||||
|
||||
await fs.writeFile(
|
||||
outputPath,
|
||||
JSON.stringify(swaggerSpec, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
systemLogger.success("OpenAPI specification generated", {
|
||||
operation: "openapi_generate_success",
|
||||
});
|
||||
} catch (error) {
|
||||
systemLogger.error("Failed to generate OpenAPI specification", error, {
|
||||
operation: "openapi_generation",
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
generateOpenAPISpec();
|
||||
|
||||
export { swaggerOptions, generateOpenAPISpec };
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from "../database/db/index.js";
|
||||
import { sshCredentials } from "../database/db/schema.js";
|
||||
import { sshCredentials, sharedCredentials } from "../database/db/schema.js";
|
||||
import { eq, and, or, isNull } from "drizzle-orm";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { SystemCrypto } from "./system-crypto.js";
|
||||
@@ -64,7 +64,7 @@ export class CredentialSystemEncryptionMigration {
|
||||
cred.keyPassword,
|
||||
userDEK,
|
||||
cred.id.toString(),
|
||||
"key_password",
|
||||
"keyPassword",
|
||||
)
|
||||
: null;
|
||||
|
||||
@@ -105,6 +105,11 @@ export class CredentialSystemEncryptionMigration {
|
||||
})
|
||||
.where(eq(sshCredentials.id, cred.id));
|
||||
|
||||
await db
|
||||
.update(sharedCredentials)
|
||||
.set({ needsReEncryption: true })
|
||||
.where(eq(sharedCredentials.originalCredentialId, cred.id));
|
||||
|
||||
migrated++;
|
||||
} catch (error) {
|
||||
databaseLogger.warn(
|
||||
|
||||
@@ -103,6 +103,27 @@ export class LazyFieldEncryption {
|
||||
}
|
||||
}
|
||||
|
||||
// Guac hosts migrated from single-protocol: rdpPassword/vncPassword/telnetPassword
|
||||
// columns were populated by copying the encrypted `password` blob. Try decrypting
|
||||
// under the original field name before giving up.
|
||||
if (
|
||||
fieldName === "rdpPassword" ||
|
||||
fieldName === "vncPassword" ||
|
||||
fieldName === "telnetPassword"
|
||||
) {
|
||||
try {
|
||||
const decrypted = FieldCrypto.decryptField(
|
||||
fieldValue,
|
||||
userKEK,
|
||||
recordId,
|
||||
"password",
|
||||
);
|
||||
return decrypted;
|
||||
} catch {
|
||||
// not encrypted as "password" either
|
||||
}
|
||||
}
|
||||
|
||||
const sensitiveFields = [
|
||||
"totpSecret",
|
||||
"totpBackupCodes",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import crypto from "crypto";
|
||||
import type { ConnectConfig, CipherAlgorithm } from "ssh2";
|
||||
|
||||
// Maps SSH cipher names to their OpenSSL equivalents (as used by ssh2 internally)
|
||||
const availableCiphers = new Set(crypto.getCiphers());
|
||||
|
||||
// Maps SSH cipher names to their OpenSSL equivalents
|
||||
const SSH_CIPHER_SSL_NAME: Partial<Record<CipherAlgorithm, string>> = {
|
||||
"chacha20-poly1305@openssh.com": "chacha20",
|
||||
"aes256-gcm@openssh.com": "aes-256-gcm",
|
||||
@@ -15,12 +17,31 @@ const SSH_CIPHER_SSL_NAME: Partial<Record<CipherAlgorithm, string>> = {
|
||||
"3des-cbc": "des-ede3-cbc",
|
||||
};
|
||||
|
||||
const availableCiphers = new Set(crypto.getCiphers());
|
||||
// Check if ssh2's native crypto binding is available (needed for chacha20-poly1305)
|
||||
let ssh2BindingAvailable = false;
|
||||
try {
|
||||
require("ssh2/lib/protocol/crypto/build/Release/sshcrypto.node");
|
||||
ssh2BindingAvailable = true;
|
||||
} catch {
|
||||
try {
|
||||
// ESM fallback: check if chacha20 works via OpenSSL createCipheriv
|
||||
crypto.createCipheriv("chacha20", Buffer.alloc(32), Buffer.alloc(16));
|
||||
ssh2BindingAvailable = true;
|
||||
} catch {
|
||||
ssh2BindingAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
function filterCiphers(list: CipherAlgorithm[]): CipherAlgorithm[] {
|
||||
return list.filter((name) => {
|
||||
const sslName = SSH_CIPHER_SSL_NAME[name];
|
||||
return !sslName || availableCiphers.has(sslName);
|
||||
if (!sslName) return true;
|
||||
if (!availableCiphers.has(sslName)) return false;
|
||||
// chacha20-poly1305 requires either native binding or working OpenSSL chacha20
|
||||
if (name === "chacha20-poly1305@openssh.com" && !ssh2BindingAvailable) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,27 @@ function detectKeyTypeFromContent(keyContent: string): string {
|
||||
function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
|
||||
const content = publicKeyContent.trim();
|
||||
|
||||
// OpenSSH certificate types (must be checked before plain key types)
|
||||
if (content.startsWith("ssh-ed25519-cert-v01@openssh.com ")) {
|
||||
return "ssh-ed25519-cert-v01@openssh.com";
|
||||
}
|
||||
if (content.startsWith("ssh-rsa-cert-v01@openssh.com ")) {
|
||||
return "ssh-rsa-cert-v01@openssh.com";
|
||||
}
|
||||
if (content.startsWith("ecdsa-sha2-nistp256-cert-v01@openssh.com ")) {
|
||||
return "ecdsa-sha2-nistp256-cert-v01@openssh.com";
|
||||
}
|
||||
if (content.startsWith("ecdsa-sha2-nistp384-cert-v01@openssh.com ")) {
|
||||
return "ecdsa-sha2-nistp384-cert-v01@openssh.com";
|
||||
}
|
||||
if (content.startsWith("ecdsa-sha2-nistp521-cert-v01@openssh.com ")) {
|
||||
return "ecdsa-sha2-nistp521-cert-v01@openssh.com";
|
||||
}
|
||||
if (content.startsWith("sk-ssh-ed25519-cert-v01@openssh.com ")) {
|
||||
return "sk-ssh-ed25519-cert-v01@openssh.com";
|
||||
}
|
||||
|
||||
// Plain public keys
|
||||
if (content.startsWith("ssh-rsa ")) {
|
||||
return "ssh-rsa";
|
||||
}
|
||||
|
||||
+64
-4
@@ -8,9 +8,11 @@ import "./ui/i18n/i18n";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { Toaster } from "@/components/sonner";
|
||||
import { Auth, getStoredAuth, clearStoredAuth } from "@/auth/Auth";
|
||||
import { getUserInfo, getCurrentToken, appReadyPromise } from "@/main-axios";
|
||||
import { applyAccentColor, applyFontSize } from "@/lib/theme";
|
||||
import type { FontSizeId } from "@/types/ui-types";
|
||||
import { useServiceWorker } from "@/hooks/use-service-worker";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const AppShell = lazy(() =>
|
||||
import("@/AppShell").then((m) => ({ default: m.AppShell })),
|
||||
@@ -34,7 +36,12 @@ const ElectronVersionCheck = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
type Phase = "idle-auth" | "fading-in" | "idle-app" | "fading-out";
|
||||
type Phase =
|
||||
| "verifying"
|
||||
| "idle-auth"
|
||||
| "fading-in"
|
||||
| "idle-app"
|
||||
| "fading-out";
|
||||
|
||||
function useWindowWidth() {
|
||||
const [width, setWidth] = useState(window.innerWidth);
|
||||
@@ -105,7 +112,7 @@ function FullscreenApp() {
|
||||
function App() {
|
||||
const stored = getStoredAuth();
|
||||
const [phase, setPhase] = useState<Phase>(
|
||||
stored?.loggedIn ? "idle-app" : "idle-auth",
|
||||
stored?.loggedIn ? "verifying" : "idle-auth",
|
||||
);
|
||||
const [authUsername, setAuthUsername] = useState(stored?.username ?? "");
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -122,10 +129,38 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Verify stored session against the server before rendering AppShell.
|
||||
// Wait for API instances to be initialized with correct embedded/server config first.
|
||||
// In Electron, also repopulate localStorage["jwt"] so WebSocket connections can auth
|
||||
// after a session restore (the token is only written to localStorage during a fresh login).
|
||||
useEffect(() => {
|
||||
if (phase !== "verifying") return;
|
||||
appReadyPromise
|
||||
.then(() => getUserInfo())
|
||||
.then(() => {
|
||||
if (isElectron()) {
|
||||
getCurrentToken()
|
||||
.then((token) => {
|
||||
if (token) localStorage.setItem("jwt", token);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
setPhase("fading-in");
|
||||
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
|
||||
})
|
||||
.catch(() => {
|
||||
clearStoredAuth();
|
||||
setPhase("idle-auth");
|
||||
});
|
||||
}, [phase]);
|
||||
|
||||
function handleLogin(u: string) {
|
||||
setAuthUsername(u);
|
||||
setPhase("fading-in");
|
||||
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
|
||||
if (isElectron()) {
|
||||
window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
@@ -144,11 +179,36 @@ function App() {
|
||||
const appOpacity = phase === "idle-app" ? 1 : 0;
|
||||
const authOpacity = phase === "idle-auth" ? 1 : 0;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const isTransitioning = phase === "fading-in" || phase === "fading-out";
|
||||
|
||||
if (phase === "verifying") {
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">{t("common.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isTransitioning && (
|
||||
<div className="fixed inset-0 z-0 flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showApp && (
|
||||
<div
|
||||
className="fixed inset-0 z-0 transition-opacity duration-[450ms] ease-in-out"
|
||||
className="fixed inset-0 z-10 transition-opacity duration-[450ms] ease-in-out"
|
||||
style={{
|
||||
opacity: appOpacity,
|
||||
pointerEvents: phase === "idle-app" ? "auto" : "none",
|
||||
@@ -162,7 +222,7 @@ function App() {
|
||||
|
||||
{showAuth && (
|
||||
<div
|
||||
className="fixed inset-0 z-10 transition-opacity duration-[450ms] ease-in-out"
|
||||
className="fixed inset-0 z-20 transition-opacity duration-[450ms] ease-in-out"
|
||||
style={{
|
||||
opacity: authOpacity,
|
||||
pointerEvents: phase === "idle-auth" ? "auto" : "none",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -105,16 +105,11 @@ export interface Host {
|
||||
vncUser?: string;
|
||||
telnetUser?: string;
|
||||
telnetPassword?: string;
|
||||
hasRdpPassword?: boolean;
|
||||
hasVncPassword?: boolean;
|
||||
hasTelnetPassword?: boolean;
|
||||
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
hasPassword?: boolean;
|
||||
hasKey?: boolean;
|
||||
hasSudoPassword?: boolean;
|
||||
hasKeyPassword?: boolean;
|
||||
|
||||
isShared?: boolean;
|
||||
permissionLevel?: "view";
|
||||
@@ -240,6 +235,10 @@ export interface Credential {
|
||||
password?: string;
|
||||
key?: string;
|
||||
publicKey?: string;
|
||||
/** CA-signed certificate file content (e.g. id_ed25519-cert.pub) */
|
||||
certPublicKey?: string;
|
||||
/** True when a cert is stored but certPublicKey content is redacted in list responses */
|
||||
hasCertPublicKey?: boolean;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
usageCount: number;
|
||||
@@ -261,6 +260,8 @@ export interface CredentialBackend {
|
||||
key: string;
|
||||
privateKey?: string;
|
||||
publicKey?: string;
|
||||
/** CA-signed certificate file content (e.g. id_ed25519-cert.pub) */
|
||||
certPublicKey?: string;
|
||||
keyPassword: string | null;
|
||||
keyType?: string;
|
||||
detectedKeyType: string;
|
||||
@@ -280,6 +281,8 @@ export interface CredentialData {
|
||||
password?: string;
|
||||
key?: string;
|
||||
publicKey?: string;
|
||||
/** CA-signed certificate file content (e.g. id_ed25519-cert.pub) */
|
||||
certPublicKey?: string | null;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
}
|
||||
@@ -6,13 +6,16 @@ export type Host = {
|
||||
port: number;
|
||||
folder: string;
|
||||
online: boolean;
|
||||
cpu: number;
|
||||
ram: number;
|
||||
cpu: number | null;
|
||||
ram: number | null;
|
||||
lastAccess: string;
|
||||
tags?: string[];
|
||||
authType: "password" | "key" | "credential" | "none" | "opkssh";
|
||||
credentialId?: string;
|
||||
overrideCredentialUsername?: boolean;
|
||||
password?: string;
|
||||
hasKey?: boolean;
|
||||
hasKeyPassword?: boolean;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
@@ -45,6 +48,7 @@ export type Host = {
|
||||
keepaliveInterval?: number;
|
||||
keepaliveCountMax?: number;
|
||||
environmentVariables: { key: string; value: string }[];
|
||||
startupSnippetId?: number | null;
|
||||
};
|
||||
|
||||
useSocks5?: boolean;
|
||||
@@ -55,7 +59,7 @@ export type Host = {
|
||||
socks5ProxyChain?: {
|
||||
host: string;
|
||||
port: number;
|
||||
type: 4 | 5 | "http";
|
||||
type: 4 | 5 | "http" | string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}[];
|
||||
@@ -118,6 +122,7 @@ export type Host = {
|
||||
telnetPassword?: string;
|
||||
|
||||
guacamoleConfig?: Record<string, any>;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
};
|
||||
|
||||
export type Credential = {
|
||||
@@ -150,7 +155,8 @@ export type TabType =
|
||||
| "user-profile"
|
||||
| "admin-settings"
|
||||
| "docker"
|
||||
| "tunnel";
|
||||
| "tunnel"
|
||||
| "network_graph";
|
||||
|
||||
export type TunnelStatusValue =
|
||||
| "CONNECTED"
|
||||
@@ -179,6 +185,10 @@ export type Tab = {
|
||||
type: TabType;
|
||||
label: string;
|
||||
host?: Host;
|
||||
terminalRef?: import("react").RefObject<{
|
||||
sendInput?: (data: string) => void;
|
||||
reconnect?: () => void;
|
||||
} | null>;
|
||||
};
|
||||
|
||||
export type DockerContainerStatus =
|
||||
@@ -265,6 +275,7 @@ export type SplitMode =
|
||||
| "none"
|
||||
| "2-way"
|
||||
| "3-way"
|
||||
| "3-way-horizontal"
|
||||
| "4-way"
|
||||
| "5-way"
|
||||
| "6-way";
|
||||
@@ -273,8 +284,8 @@ export type Snippet = {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
command: string;
|
||||
folderId: number | null;
|
||||
content: string;
|
||||
folder: string | null;
|
||||
};
|
||||
|
||||
export const FOLDER_ICONS = [
|
||||
+400
-134
@@ -1,8 +1,11 @@
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Separator } from "@/components/separator";
|
||||
import { Button } from "@/components/button";
|
||||
import { Sheet, SheetContent } from "@/components/sheet";
|
||||
import { ChevronLeft, ChevronRight, Maximize2 } from "lucide-react";
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useState, useRef, useCallback, useEffect, createRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { MobileBottomBar } from "@/shell/MobileBottomBar";
|
||||
import { CommandPalette } from "@/shell/CommandPalette";
|
||||
@@ -16,7 +19,9 @@ import { HistoryPanel } from "@/sidebar/HistoryPanel";
|
||||
import { SplitScreenPanel } from "@/sidebar/SplitScreenPanel";
|
||||
import { UserProfilePanel } from "@/sidebar/UserProfilePanel";
|
||||
import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel";
|
||||
import { CredentialsPanel } from "@/sidebar/CredentialsPanel";
|
||||
import { SplitView } from "@/shell/SplitView";
|
||||
import { renderTabContent } from "@/shell/tabUtils";
|
||||
import { TabBar } from "@/shell/TabBar";
|
||||
import type {
|
||||
Tab,
|
||||
@@ -25,7 +30,8 @@ import type {
|
||||
SplitMode,
|
||||
HostFolder,
|
||||
} from "@/types/ui-types";
|
||||
import { getSSHHosts } from "@/main-axios";
|
||||
import { getSSHHosts, getUserInfo } from "@/main-axios";
|
||||
import { dbHealthMonitor } from "@/lib/db-health-monitor";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
|
||||
function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
@@ -50,14 +56,14 @@ function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
notes: h.notes,
|
||||
pin: h.pin ?? false,
|
||||
macAddress: h.macAddress,
|
||||
enableSsh: h.enableSsh ?? (h.connectionType === "ssh" || !h.connectionType),
|
||||
enableTerminal: h.enableTerminal ?? true,
|
||||
enableTunnel: h.enableTunnel ?? false,
|
||||
enableFileManager: h.enableFileManager ?? false,
|
||||
enableDocker: h.enableDocker ?? false,
|
||||
enableSsh: h.connectionType === "ssh" || !h.connectionType,
|
||||
enableRdp: h.connectionType === "rdp",
|
||||
enableVnc: h.connectionType === "vnc",
|
||||
enableTelnet: h.connectionType === "telnet",
|
||||
enableRdp: h.enableRdp ?? h.connectionType === "rdp",
|
||||
enableVnc: h.enableVnc ?? h.connectionType === "vnc",
|
||||
enableTelnet: h.enableTelnet ?? h.connectionType === "telnet",
|
||||
sshPort: h.port,
|
||||
rdpPort: 3389,
|
||||
vncPort: 5900,
|
||||
@@ -107,19 +113,6 @@ function buildHostTree(hosts: SSHHostWithStatus[]): HostFolder {
|
||||
return root;
|
||||
}
|
||||
export { tabIcon, renderTabContent } from "@/shell/tabUtils";
|
||||
import { renderTabContent } from "@/shell/tabUtils";
|
||||
|
||||
const DASHBOARD_TAB: Tab = {
|
||||
id: "dashboard",
|
||||
type: "dashboard",
|
||||
label: "Dashboard",
|
||||
};
|
||||
|
||||
const SINGLETON_TAB_LABELS: Partial<Record<TabType, string>> = {
|
||||
"host-manager": "Host Manager",
|
||||
docker: "Docker",
|
||||
tunnel: "Tunnels",
|
||||
};
|
||||
|
||||
// ─── AppShell ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -130,7 +123,10 @@ export function AppShell({
|
||||
username: string;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const [tabs, setTabs] = useState<Tab[]>([DASHBOARD_TAB]);
|
||||
const { t } = useTranslation();
|
||||
const [tabs, setTabs] = useState<Tab[]>([
|
||||
{ id: "dashboard", type: "dashboard", label: t("nav.dashboard") },
|
||||
]);
|
||||
const [activeTabId, setActiveTabId] = useState("dashboard");
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||
const [splitMode, setSplitMode] = useState<SplitMode>("none");
|
||||
@@ -138,30 +134,77 @@ export function AppShell({
|
||||
Array(6).fill(null),
|
||||
);
|
||||
const [realHostTree, setRealHostTree] = useState<HostFolder | null>(null);
|
||||
const [hostsLoading, setHostsLoading] = useState(true);
|
||||
const [allHosts, setAllHosts] = useState<Host[]>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [railView, setRailView] = useState<RailView>("hosts");
|
||||
const [profileDropdownOpen, setProfileDropdownOpen] = useState(false);
|
||||
const [hostManagerExpanded, setHostManagerExpanded] = useState(false);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(256);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(266);
|
||||
const [sidebarDragging, setSidebarDragging] = useState(false);
|
||||
const [sidebarEditing, setSidebarEditing] = useState(false);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Close the sidebar when switching to mobile (it becomes a sheet overlay)
|
||||
const sidebarOpenBeforeMobile = useRef(sidebarOpen);
|
||||
useEffect(() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
if (isMobile) {
|
||||
sidebarOpenBeforeMobile.current = sidebarOpen;
|
||||
setSidebarOpen(false);
|
||||
} else {
|
||||
setSidebarOpen(sidebarOpenBeforeMobile.current);
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
const pendingHostManagerEditId = useRef<string | null>(null);
|
||||
const pendingHostManagerAction = useRef<"add-host" | "add-credential" | null>(
|
||||
null,
|
||||
);
|
||||
useEffect(() => {
|
||||
getUserInfo()
|
||||
.then((info) => setIsAdmin(info.is_admin))
|
||||
.catch(() => setIsAdmin(false));
|
||||
}, []);
|
||||
|
||||
const lastShiftTime = useRef(0);
|
||||
const terminalRefs = useRef<Map<string, ReturnType<typeof createRef>>>(
|
||||
new Map(),
|
||||
);
|
||||
const [paneContentEls, setPaneContentEls] = useState<
|
||||
(HTMLDivElement | null)[]
|
||||
>(Array(6).fill(null));
|
||||
|
||||
// Stable per-tab DOM nodes — created once per tab, never destroyed while the tab lives.
|
||||
// We always portal each tab's content into its own node, then move that node between
|
||||
// the normal-view container and the pane container via vanilla DOM so React's portal
|
||||
// target never changes (changing the target causes a remount).
|
||||
const tabNodesRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const normalViewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getTabNode = useCallback((tabId: string, isTerminal: boolean) => {
|
||||
if (!tabNodesRef.current.has(tabId)) {
|
||||
const el = document.createElement("div");
|
||||
el.style.position = "absolute";
|
||||
el.style.inset = "0";
|
||||
el.style.overflow = "hidden";
|
||||
if (!isTerminal) el.classList.add("bg-background");
|
||||
tabNodesRef.current.set(tabId, el);
|
||||
}
|
||||
return tabNodesRef.current.get(tabId)!;
|
||||
}, []);
|
||||
|
||||
const onPaneContentRef = useCallback(
|
||||
(paneIndex: number, el: HTMLDivElement | null) => {
|
||||
setPaneContentEls((prev) => {
|
||||
if (prev[paneIndex] === el) return prev;
|
||||
const next = [...prev];
|
||||
next[paneIndex] = el;
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const sidebarTitle: Record<RailView, string> = {
|
||||
hosts: "Hosts",
|
||||
credentials: "Credentials",
|
||||
"quick-connect": "Quick Connect",
|
||||
"ssh-tools": "SSH Tools",
|
||||
snippets: "Snippets",
|
||||
@@ -191,6 +234,60 @@ export function AppShell({
|
||||
return () => window.removeEventListener("termix:logout", handle);
|
||||
}, [onLogout]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSessionExpired = () => onLogout();
|
||||
dbHealthMonitor.on("session-expired", handleSessionExpired);
|
||||
return () => dbHealthMonitor.off("session-expired", handleSessionExpired);
|
||||
}, [onLogout]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
if (!activeTab?.terminalRef) return;
|
||||
let innerRafId: number;
|
||||
const outerRafId = requestAnimationFrame(() => {
|
||||
innerRafId = requestAnimationFrame(() => {
|
||||
const ref = activeTab.terminalRef?.current;
|
||||
ref?.fit?.();
|
||||
ref?.notifyResize?.();
|
||||
ref?.refresh?.();
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(outerRafId);
|
||||
cancelAnimationFrame(innerRafId);
|
||||
};
|
||||
}, [activeTabId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDegraded = () => {
|
||||
toast.loading(t("common.connectionDegraded"), {
|
||||
id: "db-connection-degraded",
|
||||
duration: Infinity,
|
||||
dismissible: false,
|
||||
action: {
|
||||
label: t("common.reload"),
|
||||
onClick: () => window.location.reload(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRestored = () => {
|
||||
toast.dismiss("db-connection-degraded");
|
||||
toast.success(t("common.backendReconnected"), { duration: 3000 });
|
||||
};
|
||||
|
||||
dbHealthMonitor.on("database-connection-degraded", handleDegraded);
|
||||
dbHealthMonitor.on("database-connection-degraded-cleared", handleRestored);
|
||||
|
||||
return () => {
|
||||
dbHealthMonitor.off("database-connection-degraded", handleDegraded);
|
||||
dbHealthMonitor.off(
|
||||
"database-connection-degraded-cleared",
|
||||
handleRestored,
|
||||
);
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
// Load real hosts from API
|
||||
const loadHosts = useCallback(async () => {
|
||||
try {
|
||||
@@ -200,6 +297,8 @@ export function AppShell({
|
||||
setRealHostTree(buildHostTree(raw));
|
||||
} catch {
|
||||
// Keep empty state on error
|
||||
} finally {
|
||||
setHostsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -207,6 +306,11 @@ export function AppShell({
|
||||
loadHosts();
|
||||
}, [loadHosts]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("termix:hosts-changed", loadHosts);
|
||||
return () => window.removeEventListener("termix:hosts-changed", loadHosts);
|
||||
}, [loadHosts]);
|
||||
|
||||
// Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings)
|
||||
useEffect(() => {
|
||||
if (allHosts.length === 0) return;
|
||||
@@ -230,41 +334,35 @@ export function AppShell({
|
||||
};
|
||||
window.addEventListener("termix:open-tab", handle);
|
||||
return () => window.removeEventListener("termix:open-tab", handle);
|
||||
}, [tabs, allHosts]);
|
||||
}, [allHosts]);
|
||||
|
||||
// ─── Tab management ──────────────────────────────────────────────────────
|
||||
|
||||
function openTab(host: Host, type: TabType) {
|
||||
const openTab = useCallback(function openTab(host: Host, type: TabType) {
|
||||
const tabId = `${host.name}-${type}-${Date.now()}`;
|
||||
const ref = type === "terminal" ? createRef() : undefined;
|
||||
if (ref) terminalRefs.current.set(tabId, ref);
|
||||
|
||||
setTabs((prev) => {
|
||||
const same = prev.filter(
|
||||
(t) =>
|
||||
t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name,
|
||||
);
|
||||
if (same.length === 0) {
|
||||
const tab = {
|
||||
id: `${host.name}-${type}`,
|
||||
type,
|
||||
label: host.name,
|
||||
host,
|
||||
};
|
||||
setActiveTabId(tab.id);
|
||||
return [...prev, tab];
|
||||
}
|
||||
const next = prev.map((t) =>
|
||||
t.id === same[0].id && !/\(\d+\)$/.test(t.label)
|
||||
? { ...t, label: `${host.name} (1)`, host }
|
||||
: t,
|
||||
);
|
||||
const tab = {
|
||||
id: `${host.name}-${type}-${Date.now()}`,
|
||||
type,
|
||||
label: `${host.name} (${same.length + 1})`,
|
||||
host,
|
||||
};
|
||||
setActiveTabId(tab.id);
|
||||
return [...next, tab];
|
||||
const label =
|
||||
same.length === 0 ? host.name : `${host.name} (${same.length + 1})`;
|
||||
|
||||
// Retrofit the first duplicate's label to "(1)" if needed
|
||||
const next =
|
||||
same.length === 1 && !/\(\d+\)$/.test(same[0].label)
|
||||
? prev.map((t) =>
|
||||
t.id === same[0].id ? { ...t, label: `${host.name} (1)` } : t,
|
||||
)
|
||||
: prev;
|
||||
|
||||
return [...next, { id: tabId, type, label, host, terminalRef: ref }];
|
||||
});
|
||||
}
|
||||
setActiveTabId(tabId);
|
||||
}, []);
|
||||
|
||||
function connectHost(host: Host, preferredType?: TabType) {
|
||||
const type: TabType =
|
||||
@@ -281,44 +379,104 @@ export function AppShell({
|
||||
openTab(host, type);
|
||||
}
|
||||
|
||||
function openSingletonTab(type: TabType, pendingEvent?: string) {
|
||||
if (type === "host-manager") {
|
||||
if (pendingEvent === "host-manager:add-host")
|
||||
pendingHostManagerAction.current = "add-host";
|
||||
else if (pendingEvent === "host-manager:add-credential")
|
||||
pendingHostManagerAction.current = "add-credential";
|
||||
|
||||
setHostManagerExpanded(true);
|
||||
setSidebarOpen(true);
|
||||
setRailView("hosts");
|
||||
|
||||
if (pendingEvent) {
|
||||
// Use a small delay to ensure HostManager is mounted if it wasn't already,
|
||||
// and to allow the current render cycle to complete.
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent(pendingEvent));
|
||||
}, 0);
|
||||
const openSingletonTab = useCallback(
|
||||
function openSingletonTab(type: TabType, pendingEvent?: string) {
|
||||
if (type === "host-manager") {
|
||||
if (pendingEvent === "host-manager:add-credential") {
|
||||
setSidebarOpen(true);
|
||||
setRailView("credentials");
|
||||
setTimeout(
|
||||
() =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("host-manager:add-credential"),
|
||||
),
|
||||
0,
|
||||
);
|
||||
} else {
|
||||
setSidebarOpen(true);
|
||||
setRailView("hosts");
|
||||
if (pendingEvent) {
|
||||
setTimeout(
|
||||
() => window.dispatchEvent(new CustomEvent(pendingEvent)),
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
if (type === "user-profile" || type === "admin-settings") {
|
||||
setSidebarEditing(false);
|
||||
setRailView(type as RailView);
|
||||
setSidebarOpen(true);
|
||||
return;
|
||||
}
|
||||
const id = type;
|
||||
setTabs((prev) => {
|
||||
if (prev.find((t) => t.id === id)) return prev;
|
||||
const singletonLabels: Partial<Record<TabType, string>> = {
|
||||
"host-manager": t("nav.hostManager"),
|
||||
docker: t("nav.docker"),
|
||||
tunnel: t("nav.tunnels"),
|
||||
network_graph: t("nav.networkGraph"),
|
||||
};
|
||||
return [...prev, { id, type, label: singletonLabels[type] ?? type }];
|
||||
});
|
||||
setActiveTabId(id);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const SESSION_TAB_TYPES: TabType[] = ["terminal", "rdp", "vnc", "telnet"];
|
||||
|
||||
function doCloseTab(id: string) {
|
||||
terminalRefs.current.delete(id);
|
||||
if (id === activeTabId) {
|
||||
const remaining = tabs.filter((t) => t.id !== id);
|
||||
setActiveTabId(
|
||||
remaining.length > 0 ? remaining[remaining.length - 1].id : "dashboard",
|
||||
);
|
||||
}
|
||||
if (type === "user-profile" || type === "admin-settings") {
|
||||
handleRailClick(type as RailView);
|
||||
return;
|
||||
}
|
||||
const id = type;
|
||||
setTabs((prev) => {
|
||||
if (prev.find((t) => t.id === id)) return prev;
|
||||
return [...prev, { id, type, label: SINGLETON_TAB_LABELS[type] ?? type }];
|
||||
const next = prev.filter((t) => t.id !== id);
|
||||
if (next.length === 0)
|
||||
return [
|
||||
{ id: "dashboard", type: "dashboard", label: t("nav.dashboard") },
|
||||
];
|
||||
return next;
|
||||
});
|
||||
setActiveTabId(id);
|
||||
}
|
||||
|
||||
function refreshTab(id: string) {
|
||||
const tab = tabs.find((t) => t.id === id);
|
||||
if (!tab) return;
|
||||
if (tab.type === "terminal") {
|
||||
const ref = tab.terminalRef?.current;
|
||||
ref?.reconnect?.();
|
||||
} else if (["rdp", "vnc", "telnet"].includes(tab.type)) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("termix:refresh-guacamole", { detail: { tabId: id } }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function closeTab(id: string) {
|
||||
setTabs((prev) => {
|
||||
const next = prev.filter((t) => t.id !== id);
|
||||
if (id === activeTabId) setActiveTabId(next[next.length - 1].id);
|
||||
return next;
|
||||
});
|
||||
const tab = tabs.find((t) => t.id === id);
|
||||
const confirmEnabled = localStorage.getItem("confirmTabClose") === "true";
|
||||
if (tab && SESSION_TAB_TYPES.includes(tab.type) && confirmEnabled) {
|
||||
toast(t("nav.confirmClose"), {
|
||||
duration: 5000,
|
||||
action: {
|
||||
label: t("nav.close"),
|
||||
onClick: () => doCloseTab(id),
|
||||
},
|
||||
cancel: {
|
||||
label: t("nav.cancel"),
|
||||
onClick: () => {},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
doCloseTab(id);
|
||||
}
|
||||
|
||||
// ─── Rail / sidebar ──────────────────────────────────────────────────────
|
||||
@@ -327,16 +485,20 @@ export function AppShell({
|
||||
if (railView === view && sidebarOpen) {
|
||||
setSidebarOpen(false);
|
||||
} else {
|
||||
if (view !== railView) setSidebarEditing(false);
|
||||
setRailView(view);
|
||||
setSidebarOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
function editHostInManager(host: Host) {
|
||||
pendingHostManagerEditId.current = host.id;
|
||||
setHostManagerExpanded(true);
|
||||
setSidebarOpen(true);
|
||||
setRailView("hosts");
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("host-manager:edit-host", { detail: host.id }),
|
||||
);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const onSidebarMouseDown = useCallback(
|
||||
@@ -361,34 +523,114 @@ export function AppShell({
|
||||
[sidebarWidth],
|
||||
);
|
||||
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId)!;
|
||||
// Resize all terminals in panes + active terminal when split mode or sidebar changes
|
||||
const resizeAllTerminals = useCallback(() => {
|
||||
const id = requestAnimationFrame(() => {
|
||||
tabs.forEach((tab) => {
|
||||
if (!tab.terminalRef) return;
|
||||
const ref = tab.terminalRef.current as any;
|
||||
ref?.fit?.();
|
||||
ref?.notifyResize?.();
|
||||
});
|
||||
});
|
||||
return id;
|
||||
}, [tabs]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = resizeAllTerminals();
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [splitMode, sidebarWidth, sidebarOpen]);
|
||||
|
||||
const isSplit = splitMode !== "none";
|
||||
|
||||
// Move each tab's stable DOM node to the right container (pane or normal-view).
|
||||
// This is vanilla DOM so React's portal target never changes — changing the portal
|
||||
// target causes a remount which is exactly what we're trying to avoid.
|
||||
useEffect(() => {
|
||||
const normalView = normalViewRef.current;
|
||||
if (!normalView) return;
|
||||
|
||||
const tabIds = new Set(tabs.map((t) => t.id));
|
||||
|
||||
// Remove nodes for closed tabs
|
||||
for (const [id, node] of tabNodesRef.current) {
|
||||
if (!tabIds.has(id)) {
|
||||
node.remove();
|
||||
tabNodesRef.current.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tab of tabs) {
|
||||
const isTerminal = tab.type === "terminal";
|
||||
const node = getTabNode(tab.id, isTerminal);
|
||||
const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1;
|
||||
const inPane = paneIdx !== -1;
|
||||
const paneEl = inPane ? paneContentEls[paneIdx] : null;
|
||||
const activeInline = !inPane && tab.id === activeTabId;
|
||||
|
||||
if (inPane && paneEl) {
|
||||
if (node.parentElement !== paneEl) paneEl.appendChild(node);
|
||||
node.style.visibility = "visible";
|
||||
node.style.pointerEvents = "auto";
|
||||
node.style.display = "";
|
||||
node.style.zIndex = "";
|
||||
} else {
|
||||
if (node.parentElement !== normalView) normalView.appendChild(node);
|
||||
if (isTerminal) {
|
||||
node.style.display = "";
|
||||
node.style.visibility = activeInline ? "visible" : "hidden";
|
||||
node.style.pointerEvents = activeInline ? "auto" : "none";
|
||||
node.style.zIndex = activeInline && !isSplit ? "1" : "0";
|
||||
} else {
|
||||
node.style.visibility = "";
|
||||
node.style.pointerEvents = "";
|
||||
node.style.zIndex = activeInline ? "2" : "";
|
||||
node.style.display = activeInline ? "" : "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId)!;
|
||||
const terminalTabs = tabs.filter((t) => t.type === "terminal");
|
||||
|
||||
// Sidebar panel content — shared between desktop inline sidebar and mobile sheet
|
||||
const sidebarPanelContent = (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{railView === "hosts" && (
|
||||
<div
|
||||
className={`flex flex-col flex-1 min-h-0 ${railView === "hosts" ? "" : "hidden"}`}
|
||||
>
|
||||
<HostsPanel
|
||||
expanded={hostManagerExpanded}
|
||||
onExpand={() => setHostManagerExpanded(true)}
|
||||
onCollapse={() => {
|
||||
setHostManagerExpanded(false);
|
||||
loadHosts();
|
||||
}}
|
||||
pendingEditId={pendingHostManagerEditId}
|
||||
pendingAction={pendingHostManagerAction}
|
||||
onOpenTab={(host, type) => {
|
||||
connectHost(host, type);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
onEditHost={editHostInManager}
|
||||
hostTree={realHostTree ?? undefined}
|
||||
loading={hostsLoading}
|
||||
onEditingChange={setSidebarEditing}
|
||||
active={railView === "hosts"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex flex-col flex-1 min-h-0 ${railView === "credentials" ? "" : "hidden"}`}
|
||||
>
|
||||
<CredentialsPanel
|
||||
onEditingChange={setSidebarEditing}
|
||||
active={railView === "credentials"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{railView === "quick-connect" && (
|
||||
<QuickConnectPanel
|
||||
onConnect={(host, type) => {
|
||||
openTab(host, type);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{railView === "quick-connect" && <QuickConnectPanel />}
|
||||
|
||||
{railView === "ssh-tools" && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<SshToolsPanel
|
||||
@@ -431,7 +673,7 @@ export function AppShell({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{railView === "admin-settings" && (
|
||||
{railView === "admin-settings" && isAdmin && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<AdminSettingsPanel />
|
||||
</div>
|
||||
@@ -445,15 +687,15 @@ export function AppShell({
|
||||
<span className="flex-1 text-base font-bold tracking-tight text-foreground px-3">
|
||||
{sidebarTitle[railView]}
|
||||
</span>
|
||||
{!hostManagerExpanded && !isMobile && (
|
||||
{!isMobile && (
|
||||
<>
|
||||
<Separator orientation="vertical" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
|
||||
className="h-full w-12.5 border-y-0 border-border rounded-none text-muted-foreground hover:text-foreground"
|
||||
title="Reset width"
|
||||
onClick={() => setSidebarWidth(256)}
|
||||
onClick={() => setSidebarWidth(266)}
|
||||
>
|
||||
<Maximize2 className="size-3.5" />
|
||||
</Button>
|
||||
@@ -464,10 +706,7 @@ export function AppShell({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setSidebarOpen(false);
|
||||
setHostManagerExpanded(false);
|
||||
}}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
@@ -483,9 +722,11 @@ export function AppShell({
|
||||
sidebarOpen={sidebarOpen}
|
||||
splitMode={splitMode}
|
||||
username={username}
|
||||
isAdmin={isAdmin}
|
||||
profileDropdownOpen={profileDropdownOpen}
|
||||
onProfileDropdownChange={setProfileDropdownOpen}
|
||||
onRailClick={handleRailClick}
|
||||
onOpenTab={openSingletonTab}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
|
||||
@@ -494,18 +735,14 @@ export function AppShell({
|
||||
<div
|
||||
className={`relative flex flex-col bg-sidebar shrink-0 overflow-hidden ${sidebarOpen ? `border-r transition-colors ${sidebarDragging ? "border-accent-brand/60" : "border-border"}` : ""}`}
|
||||
style={{
|
||||
width: sidebarOpen
|
||||
? hostManagerExpanded && railView === "hosts"
|
||||
? 720
|
||||
: sidebarWidth
|
||||
: 0,
|
||||
width: sidebarOpen ? (sidebarEditing ? 560 : sidebarWidth) : 0,
|
||||
transition: sidebarDragging ? "none" : "width 0.2s",
|
||||
}}
|
||||
>
|
||||
{sidebarHeader}
|
||||
{sidebarPanelContent}
|
||||
|
||||
{sidebarOpen && !(hostManagerExpanded && railView === "hosts") && (
|
||||
{sidebarOpen && !sidebarEditing && (
|
||||
<div
|
||||
onMouseDown={onSidebarMouseDown}
|
||||
className={`absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-30 transition-colors ${sidebarDragging ? "bg-accent-brand/60" : "hover:bg-accent-brand/40"}`}
|
||||
@@ -516,13 +753,7 @@ export function AppShell({
|
||||
|
||||
{/* Mobile: sidebar as overlay sheet */}
|
||||
{isMobile && (
|
||||
<Sheet
|
||||
open={sidebarOpen}
|
||||
onOpenChange={(open) => {
|
||||
setSidebarOpen(open);
|
||||
if (!open) setHostManagerExpanded(false);
|
||||
}}
|
||||
>
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<SheetContent
|
||||
side="left"
|
||||
showCloseButton={false}
|
||||
@@ -554,20 +785,57 @@ export function AppShell({
|
||||
activeTabId={activeTabId}
|
||||
onSetActiveTab={setActiveTabId}
|
||||
onCloseTab={closeTab}
|
||||
onRefreshTab={refreshTab}
|
||||
onReorderTabs={setTabs}
|
||||
/>
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{isSplit && !isMobile ? (
|
||||
<SplitView
|
||||
tabs={tabs}
|
||||
paneTabIds={paneTabIds}
|
||||
splitMode={splitMode}
|
||||
onOpenSingletonTab={openSingletonTab}
|
||||
onOpenTab={openTab}
|
||||
/>
|
||||
) : activeTab ? (
|
||||
renderTabContent(activeTab, openSingletonTab, openTab)
|
||||
) : null}
|
||||
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{/* Split view — always mounted when not mobile, hidden via CSS when inactive */}
|
||||
{!isMobile && (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
display: isSplit ? "flex" : "none",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<SplitView
|
||||
tabs={tabs}
|
||||
paneTabIds={paneTabIds}
|
||||
splitMode={splitMode}
|
||||
onTerminalResize={resizeAllTerminals}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Normal-view container. Tab nodes are appended here (or to pane elements)
|
||||
by the DOM-placement effect above. React portals each tab's content
|
||||
into its stable per-tab node so the component is never remounted.
|
||||
Hidden when split is active — pane-assigned nodes escape via vanilla DOM
|
||||
appendChild to paneEl, so hiding this doesn't affect them. */}
|
||||
<div
|
||||
ref={normalViewRef}
|
||||
className="absolute inset-0"
|
||||
style={{ display: isSplit && !isMobile ? "none" : undefined }}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const tabNode = getTabNode(tab.id, tab.type === "terminal");
|
||||
const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1;
|
||||
const inPane = paneIdx !== -1;
|
||||
const activeInline = !inPane && tab.id === activeTabId;
|
||||
return createPortal(
|
||||
renderTabContent(
|
||||
tab,
|
||||
openSingletonTab,
|
||||
openTab,
|
||||
closeTab,
|
||||
inPane || activeInline,
|
||||
),
|
||||
tabNode,
|
||||
tab.id,
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -592,8 +860,6 @@ export function AppShell({
|
||||
"host-manager",
|
||||
"user-profile",
|
||||
"admin-settings",
|
||||
"docker",
|
||||
"tunnel",
|
||||
].includes(type)
|
||||
) {
|
||||
openSingletonTab(type, pendingEvent);
|
||||
|
||||
@@ -1,498 +0,0 @@
|
||||
import React from "react";
|
||||
import { useSidebar } from "@/components/sidebar.tsx";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/tabs.tsx";
|
||||
import { Shield, Users, Database, Clock, Key } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
getAdminOIDCConfig,
|
||||
getRegistrationAllowed,
|
||||
getPasswordLoginAllowed,
|
||||
getPasswordResetAllowed,
|
||||
getUserList,
|
||||
getUserInfo,
|
||||
isElectron,
|
||||
getSessions,
|
||||
unlinkOIDCFromPasswordAccount,
|
||||
} from "@/main-axios.ts";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import { RolesTab } from "@/admin/tabs/RolesTab.tsx";
|
||||
import { GeneralSettingsTab } from "@/admin/tabs/GeneralSettingsTab.tsx";
|
||||
import { OIDCSettingsTab } from "@/admin/tabs/OIDCSettingsTab.tsx";
|
||||
import { UserManagementTab } from "@/admin/tabs/UserManagementTab.tsx";
|
||||
import { SessionManagementTab } from "@/admin/tabs/SessionManagementTab.tsx";
|
||||
import { DatabaseSecurityTab } from "@/admin/tabs/DatabaseSecurityTab.tsx";
|
||||
import { ApiKeysTab } from "@/admin/tabs/ApiKeysTab.tsx";
|
||||
import { CreateUserDialog } from "./dialogs/CreateUserDialog.tsx";
|
||||
import { UserEditDialog } from "./dialogs/UserEditDialog.tsx";
|
||||
import { LinkAccountDialog } from "./dialogs/LinkAccountDialog.tsx";
|
||||
|
||||
interface AdminSettingsProps {
|
||||
isTopbarOpen?: boolean;
|
||||
rightSidebarOpen?: boolean;
|
||||
rightSidebarWidth?: number;
|
||||
}
|
||||
|
||||
export function AdminSettings({
|
||||
isTopbarOpen = true,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 400,
|
||||
}: AdminSettingsProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
||||
const [allowPasswordLogin, setAllowPasswordLogin] = React.useState(true);
|
||||
const [allowPasswordReset, setAllowPasswordReset] = React.useState(true);
|
||||
|
||||
const [oidcConfig, setOidcConfig] = React.useState({
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
issuer_url: "",
|
||||
authorization_url: "",
|
||||
token_url: "",
|
||||
identifier_path: "sub",
|
||||
name_path: "name",
|
||||
scopes: "openid email profile",
|
||||
userinfo_url: "",
|
||||
allowed_users: "",
|
||||
});
|
||||
|
||||
const [users, setUsers] = React.useState<
|
||||
Array<{
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash?: string;
|
||||
}>
|
||||
>([]);
|
||||
const [usersLoading, setUsersLoading] = React.useState(false);
|
||||
|
||||
const [createUserDialogOpen, setCreateUserDialogOpen] = React.useState(false);
|
||||
const [userEditDialogOpen, setUserEditDialogOpen] = React.useState(false);
|
||||
const [selectedUserForEdit, setSelectedUserForEdit] = React.useState<{
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash?: string;
|
||||
} | null>(null);
|
||||
|
||||
const [currentUser, setCurrentUser] = React.useState<{
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const [sessions, setSessions] = React.useState<
|
||||
Array<{
|
||||
id: string;
|
||||
userId: string;
|
||||
username?: string;
|
||||
deviceType: string;
|
||||
deviceInfo: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
lastActiveAt: string;
|
||||
isRevoked?: boolean;
|
||||
isCurrentSession?: boolean;
|
||||
}>
|
||||
>([]);
|
||||
const [sessionsLoading, setSessionsLoading] = React.useState(false);
|
||||
|
||||
const [linkAccountAlertOpen, setLinkAccountAlertOpen] = React.useState(false);
|
||||
const [linkOidcUser, setLinkOidcUser] = React.useState<{
|
||||
id: string;
|
||||
username: string;
|
||||
} | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Promise.allSettled([
|
||||
getAdminOIDCConfig()
|
||||
.then((res) => {
|
||||
if (res) setOidcConfig(res);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!err.message?.includes("No server configured")) {
|
||||
toast.error(t("admin.failedToFetchOidcConfig"));
|
||||
}
|
||||
}),
|
||||
getUserInfo()
|
||||
.then((info) => {
|
||||
if (info) {
|
||||
setCurrentUser({
|
||||
id: info.userId,
|
||||
username: info.username,
|
||||
is_admin: info.is_admin,
|
||||
is_oidc: info.is_oidc,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!err?.message?.includes("No server configured")) {
|
||||
console.warn("Failed to fetch current user info", err);
|
||||
}
|
||||
}),
|
||||
getSessions()
|
||||
.then((data) => setSessions(data.sessions || []))
|
||||
.catch((err) => {
|
||||
if (!err?.message?.includes("No server configured")) {
|
||||
toast.error(t("admin.failedToFetchSessions"));
|
||||
}
|
||||
}),
|
||||
]).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
getRegistrationAllowed()
|
||||
.then((res) => {
|
||||
if (typeof res?.allowed === "boolean") {
|
||||
setAllowRegistration(res.allowed);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!err.message?.includes("No server configured")) {
|
||||
toast.error(t("admin.failedToFetchRegistrationStatus"));
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
getPasswordLoginAllowed()
|
||||
.then((res) => {
|
||||
if (typeof res?.allowed === "boolean") {
|
||||
setAllowPasswordLogin(res.allowed);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.code !== "NO_SERVER_CONFIGURED") {
|
||||
toast.error(t("admin.failedToFetchPasswordLoginStatus"));
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
getPasswordResetAllowed()
|
||||
.then((res) => {
|
||||
if (typeof res === "boolean") {
|
||||
setAllowPasswordReset(res);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.code !== "NO_SERVER_CONFIGURED") {
|
||||
console.warn("Failed to fetch password reset status", err);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setUsersLoading(true);
|
||||
try {
|
||||
const response = await getUserList();
|
||||
setUsers(response.users);
|
||||
} catch (err) {
|
||||
if (!err.message?.includes("No server configured")) {
|
||||
toast.error(t("admin.failedToFetchUsers"));
|
||||
}
|
||||
} finally {
|
||||
setUsersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditUser = (user: (typeof users)[0]) => {
|
||||
setSelectedUserForEdit(user);
|
||||
setUserEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateUserSuccess = () => {
|
||||
fetchUsers();
|
||||
setCreateUserDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleEditUserSuccess = () => {
|
||||
fetchUsers();
|
||||
setUserEditDialogOpen(false);
|
||||
setSelectedUserForEdit(null);
|
||||
};
|
||||
|
||||
const fetchSessions = async () => {
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSessionsLoading(true);
|
||||
try {
|
||||
const data = await getSessions();
|
||||
setSessions(data.sessions || []);
|
||||
} catch (err) {
|
||||
if (!err?.message?.includes("No server configured")) {
|
||||
toast.error(t("admin.failedToFetchSessions"));
|
||||
}
|
||||
} finally {
|
||||
setSessionsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLinkOIDCUser = (user: { id: string; username: string }) => {
|
||||
setLinkOidcUser(user);
|
||||
setLinkAccountAlertOpen(true);
|
||||
};
|
||||
|
||||
const handleLinkSuccess = () => {
|
||||
fetchUsers();
|
||||
fetchSessions();
|
||||
};
|
||||
|
||||
const handleUnlinkOIDC = async (userId: string, username: string) => {
|
||||
confirmWithToast(
|
||||
t("admin.unlinkOIDCDescription", { username }),
|
||||
async () => {
|
||||
try {
|
||||
const result = await unlinkOIDCFromPasswordAccount(userId);
|
||||
|
||||
toast.success(
|
||||
result.message || t("admin.unlinkOIDCSuccess", { username }),
|
||||
);
|
||||
fetchUsers();
|
||||
fetchSessions();
|
||||
} catch (error: unknown) {
|
||||
const err = error as {
|
||||
response?: { data?: { error?: string; code?: string } };
|
||||
};
|
||||
toast.error(
|
||||
err.response?.data?.error || t("admin.failedToUnlinkOIDC"),
|
||||
);
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
const wrapperStyle: React.CSSProperties = {
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: rightSidebarOpen
|
||||
? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)`
|
||||
: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
transition:
|
||||
"margin-left 200ms linear, margin-right 200ms linear, margin-top 200ms linear",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={wrapperStyle}
|
||||
className="bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden"
|
||||
>
|
||||
<SimpleLoader visible={loading} message={t("common.loading")} />
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||
<h1 className="font-bold text-lg">{t("admin.title")}</h1>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="px-6 py-4 overflow-auto thin-scrollbar">
|
||||
<Tabs
|
||||
defaultValue="registration"
|
||||
onValueChange={(value) => {
|
||||
if (value === "users") {
|
||||
fetchUsers();
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="mb-4 bg-elevated border-2 border-edge">
|
||||
<TabsTrigger
|
||||
value="registration"
|
||||
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
{t("admin.general")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="oidc"
|
||||
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
OIDC
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="users"
|
||||
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
{t("admin.users")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="sessions"
|
||||
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
Sessions
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="roles"
|
||||
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
{t("rbac.roles.label")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="security"
|
||||
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
|
||||
>
|
||||
<Database className="h-4 w-4" />
|
||||
{t("admin.databaseSecurity")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="api-keys"
|
||||
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
{t("admin.apiKeys.tabLabel")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="registration" className="space-y-6">
|
||||
<GeneralSettingsTab
|
||||
allowRegistration={allowRegistration}
|
||||
setAllowRegistration={setAllowRegistration}
|
||||
allowPasswordLogin={allowPasswordLogin}
|
||||
setAllowPasswordLogin={setAllowPasswordLogin}
|
||||
allowPasswordReset={allowPasswordReset}
|
||||
setAllowPasswordReset={setAllowPasswordReset}
|
||||
oidcConfig={oidcConfig}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="oidc" className="space-y-6">
|
||||
<OIDCSettingsTab
|
||||
allowPasswordLogin={allowPasswordLogin}
|
||||
oidcConfig={oidcConfig}
|
||||
setOidcConfig={setOidcConfig}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="users" className="space-y-6">
|
||||
<UserManagementTab
|
||||
users={users}
|
||||
usersLoading={usersLoading}
|
||||
allowPasswordLogin={allowPasswordLogin}
|
||||
fetchUsers={fetchUsers}
|
||||
onCreateUser={() => setCreateUserDialogOpen(true)}
|
||||
onEditUser={handleEditUser}
|
||||
onLinkOIDCUser={handleLinkOIDCUser}
|
||||
onUnlinkOIDC={handleUnlinkOIDC}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sessions" className="space-y-6">
|
||||
<SessionManagementTab
|
||||
sessions={sessions}
|
||||
sessionsLoading={sessionsLoading}
|
||||
fetchSessions={fetchSessions}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="roles" className="space-y-6">
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<RolesTab />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="space-y-6">
|
||||
<DatabaseSecurityTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="api-keys" className="space-y-6">
|
||||
<ApiKeysTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateUserDialog
|
||||
open={createUserDialogOpen}
|
||||
onOpenChange={setCreateUserDialogOpen}
|
||||
onSuccess={handleCreateUserSuccess}
|
||||
/>
|
||||
|
||||
<UserEditDialog
|
||||
open={userEditDialogOpen}
|
||||
onOpenChange={setUserEditDialogOpen}
|
||||
user={selectedUserForEdit}
|
||||
currentUser={currentUser}
|
||||
onSuccess={handleEditUserSuccess}
|
||||
/>
|
||||
|
||||
<LinkAccountDialog
|
||||
open={linkAccountAlertOpen}
|
||||
onOpenChange={setLinkAccountAlertOpen}
|
||||
oidcUser={linkOidcUser}
|
||||
onSuccess={handleLinkSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminSettings;
|
||||
@@ -1,163 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/dialog.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { PasswordInput } from "@/components/password-input.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserPlus, AlertCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { registerUser } from "@/main-axios.ts";
|
||||
|
||||
interface CreateUserDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function CreateUserDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: CreateUserDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleCreateUser = async (e?: React.FormEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (!username.trim()) {
|
||||
setError(t("admin.enterUsername"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password.trim()) {
|
||||
setError(t("admin.enterPassword"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError("Password must be at least 6 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await registerUser(username.trim(), password);
|
||||
toast.success(
|
||||
t("admin.userCreatedSuccessfully", { username: username.trim() }),
|
||||
);
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
const errorMessage =
|
||||
error?.response?.data?.error || t("admin.failedToCreateUser");
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
if (!loading) {
|
||||
onOpenChange(newOpen);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserPlus className="w-5 h-5" />
|
||||
{t("admin.createUser")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{t("admin.createUserDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleCreateUser} className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-username">{t("admin.username")}</Label>
|
||||
<Input
|
||||
id="create-username"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={t("admin.enterUsername")}
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-password">{t("common.password")}</Label>
|
||||
<PasswordInput
|
||||
id="create-password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={t("admin.enterPassword")}
|
||||
disabled={loading}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleCreateUser();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.passwordMinLength")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => handleCreateUser()} disabled={loading}>
|
||||
{loading ? t("common.creating") : t("admin.createUser")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { linkOIDCToPasswordAccount } from "@/main-axios.ts";
|
||||
|
||||
interface LinkAccountDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
oidcUser: { id: string; username: string } | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function LinkAccountDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
oidcUser,
|
||||
onSuccess,
|
||||
}: LinkAccountDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [linkTargetUsername, setLinkTargetUsername] = useState("");
|
||||
const [linkLoading, setLinkLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setLinkTargetUsername("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleLinkSubmit = async () => {
|
||||
if (!oidcUser || !linkTargetUsername.trim()) {
|
||||
toast.error("Target username is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setLinkLoading(true);
|
||||
try {
|
||||
const result = await linkOIDCToPasswordAccount(
|
||||
oidcUser.id,
|
||||
linkTargetUsername.trim(),
|
||||
);
|
||||
|
||||
toast.success(
|
||||
result.message ||
|
||||
`OIDC user ${oidcUser.username} linked to ${linkTargetUsername}`,
|
||||
);
|
||||
setLinkTargetUsername("");
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (error: unknown) {
|
||||
const err = error as {
|
||||
response?: { data?: { error?: string; code?: string } };
|
||||
};
|
||||
toast.error(err.response?.data?.error || "Failed to link accounts");
|
||||
} finally {
|
||||
setLinkLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5" />
|
||||
{t("admin.linkOidcToPasswordAccount")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{t("admin.linkOidcToPasswordAccountDescription", {
|
||||
username: oidcUser?.username,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("admin.linkOidcWarningTitle")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("admin.linkOidcWarningDescription")}
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>{t("admin.linkOidcActionDeleteUser")}</li>
|
||||
<li>{t("admin.linkOidcActionAddCapability")}</li>
|
||||
<li>{t("admin.linkOidcActionDualAuth")}</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="link-target-username"
|
||||
className="text-base font-semibold text-foreground"
|
||||
>
|
||||
{t("admin.linkTargetUsernameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="link-target-username"
|
||||
value={linkTargetUsername}
|
||||
onChange={(e) => setLinkTargetUsername(e.target.value)}
|
||||
placeholder={t("admin.linkTargetUsernamePlaceholder")}
|
||||
disabled={linkLoading}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && linkTargetUsername.trim()) {
|
||||
handleLinkSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={linkLoading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleLinkSubmit}
|
||||
disabled={linkLoading || !linkTargetUsername.trim()}
|
||||
variant="destructive"
|
||||
>
|
||||
{linkLoading
|
||||
? t("admin.linkingAccounts")
|
||||
: t("admin.linkAccountsButton")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,536 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { Badge } from "@/components/badge.tsx";
|
||||
import { Switch } from "@/components/switch.tsx";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
UserCog,
|
||||
Trash2,
|
||||
Plus,
|
||||
AlertCircle,
|
||||
Shield,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
getUserRoles,
|
||||
getRoles,
|
||||
assignRoleToUser,
|
||||
removeRoleFromUser,
|
||||
makeUserAdmin,
|
||||
removeAdminStatus,
|
||||
revokeAllUserSessions,
|
||||
deleteUser,
|
||||
type UserRole,
|
||||
type Role,
|
||||
} from "@/main-axios.ts";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash?: string;
|
||||
}
|
||||
|
||||
interface UserEditDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
user: User | null;
|
||||
currentUser: { id: string; username: string } | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function UserEditDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
user,
|
||||
currentUser,
|
||||
onSuccess,
|
||||
}: UserEditDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const [adminLoading, setAdminLoading] = useState(false);
|
||||
const [sessionLoading, setSessionLoading] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [rolesLoading, setRolesLoading] = useState(false);
|
||||
|
||||
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
|
||||
const [availableRoles, setAvailableRoles] = useState<Role[]>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
const isCurrentUser = user?.id === currentUser?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (open && user) {
|
||||
setIsAdmin(user.isAdmin);
|
||||
loadRoles();
|
||||
}
|
||||
}, [open, user]);
|
||||
|
||||
const loadRoles = async () => {
|
||||
if (!user) return;
|
||||
|
||||
setRolesLoading(true);
|
||||
try {
|
||||
const [rolesResponse, allRolesResponse] = await Promise.all([
|
||||
getUserRoles(user.id),
|
||||
getRoles(),
|
||||
]);
|
||||
|
||||
setUserRoles(rolesResponse.roles || []);
|
||||
setAvailableRoles(allRolesResponse.roles || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to load roles:", error);
|
||||
toast.error(t("rbac.failedToLoadRoles"));
|
||||
} finally {
|
||||
setRolesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAdmin = async (checked: boolean) => {
|
||||
if (!user) return;
|
||||
|
||||
if (isCurrentUser) {
|
||||
toast.error(t("admin.cannotRemoveOwnAdmin"));
|
||||
return;
|
||||
}
|
||||
|
||||
const userToUpdate = user;
|
||||
onOpenChange(false);
|
||||
|
||||
const confirmed = await confirmWithToast({
|
||||
title: checked ? t("admin.makeUserAdmin") : t("admin.removeAdmin"),
|
||||
description: checked
|
||||
? t("admin.confirmMakeAdmin", { username: userToUpdate.username })
|
||||
: t("admin.confirmRemoveAdmin", { username: userToUpdate.username }),
|
||||
confirmText: checked ? t("admin.makeAdmin") : t("admin.removeAdmin"),
|
||||
cancelText: t("common.cancel"),
|
||||
variant: checked ? "default" : "destructive",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
onOpenChange(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setAdminLoading(true);
|
||||
try {
|
||||
if (checked) {
|
||||
await makeUserAdmin(userToUpdate.id);
|
||||
toast.success(
|
||||
t("admin.userIsNowAdmin", { username: userToUpdate.username }),
|
||||
);
|
||||
} else {
|
||||
await removeAdminStatus(userToUpdate.id);
|
||||
toast.success(
|
||||
t("admin.adminStatusRemoved", { username: userToUpdate.username }),
|
||||
);
|
||||
}
|
||||
setIsAdmin(checked);
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle admin status:", error);
|
||||
toast.error(
|
||||
checked
|
||||
? t("admin.failedToMakeUserAdmin")
|
||||
: t("admin.failedToRemoveAdminStatus"),
|
||||
);
|
||||
onOpenChange(true);
|
||||
} finally {
|
||||
setAdminLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignRole = async (roleId: number) => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
await assignRoleToUser(user.id, roleId);
|
||||
toast.success(
|
||||
t("rbac.roleAssignedSuccessfully", { username: user.username }),
|
||||
);
|
||||
await loadRoles();
|
||||
} catch (error) {
|
||||
console.error("Failed to assign role:", error);
|
||||
toast.error(t("rbac.failedToAssignRole"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveRole = async (roleId: number) => {
|
||||
if (!user) return;
|
||||
|
||||
const userToUpdate = user;
|
||||
onOpenChange(false);
|
||||
|
||||
const confirmed = await confirmWithToast({
|
||||
title: t("rbac.confirmRemoveRole"),
|
||||
description: t("rbac.confirmRemoveRoleDescription"),
|
||||
confirmText: t("common.remove"),
|
||||
cancelText: t("common.cancel"),
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
onOpenChange(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await removeRoleFromUser(userToUpdate.id, roleId);
|
||||
toast.success(
|
||||
t("rbac.roleRemovedSuccessfully", { username: userToUpdate.username }),
|
||||
);
|
||||
await loadRoles();
|
||||
onOpenChange(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to remove role:", error);
|
||||
toast.error(t("rbac.failedToRemoveRole"));
|
||||
onOpenChange(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeAllSessions = async () => {
|
||||
if (!user) return;
|
||||
|
||||
const isRevokingSelf = isCurrentUser;
|
||||
|
||||
const userToUpdate = user;
|
||||
onOpenChange(false);
|
||||
|
||||
const confirmed = await confirmWithToast({
|
||||
title: t("admin.revokeAllSessions"),
|
||||
description: isRevokingSelf
|
||||
? t("admin.confirmRevokeOwnSessions")
|
||||
: t("admin.confirmRevokeAllSessions"),
|
||||
confirmText: t("admin.revoke"),
|
||||
cancelText: t("common.cancel"),
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
onOpenChange(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setSessionLoading(true);
|
||||
try {
|
||||
const data = await revokeAllUserSessions(userToUpdate.id);
|
||||
toast.success(data.message || t("admin.sessionsRevokedSuccessfully"));
|
||||
|
||||
if (isRevokingSelf) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
onSuccess();
|
||||
onOpenChange(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to revoke sessions:", error);
|
||||
toast.error(t("admin.failedToRevokeSessions"));
|
||||
onOpenChange(true);
|
||||
} finally {
|
||||
setSessionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!user) return;
|
||||
|
||||
if (isCurrentUser) {
|
||||
toast.error(t("admin.cannotDeleteSelf"));
|
||||
return;
|
||||
}
|
||||
|
||||
const userToDelete = user;
|
||||
onOpenChange(false);
|
||||
|
||||
const confirmed = await confirmWithToast({
|
||||
title: t("admin.deleteUserTitle"),
|
||||
description: t("admin.deleteUser", { username: userToDelete.username }),
|
||||
confirmText: t("common.delete"),
|
||||
cancelText: t("common.cancel"),
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
onOpenChange(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
await deleteUser(userToDelete.username);
|
||||
toast.success(
|
||||
t("admin.userDeletedSuccessfully", { username: userToDelete.username }),
|
||||
);
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete user:", error);
|
||||
toast.error(t("admin.failedToDeleteUser"));
|
||||
onOpenChange(true);
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getAuthTypeDisplay = (): string => {
|
||||
if (!user) return "";
|
||||
if (user.isOidc && user.passwordHash) {
|
||||
return t("admin.dualAuth");
|
||||
} else if (user.isOidc) {
|
||||
return t("admin.externalOIDC");
|
||||
} else {
|
||||
return t("admin.localPassword");
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl bg-canvas border-2 border-edge">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserCog className="w-5 h-5" />
|
||||
{t("admin.manageUser")}: {user.username}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{t("admin.manageUserDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4 max-h-[70vh] overflow-y-auto thin-scrollbar pr-2">
|
||||
<div className="grid grid-cols-2 gap-4 p-4 bg-surface rounded-lg border border-edge">
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
{t("admin.username")}
|
||||
</Label>
|
||||
<p className="font-medium">{user.username}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
{t("admin.authType")}
|
||||
</Label>
|
||||
<p className="font-medium">{getAuthTypeDisplay()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
{t("admin.adminStatus")}
|
||||
</Label>
|
||||
<p className="font-medium">
|
||||
{isAdmin ? (
|
||||
<Badge variant="secondary">{t("admin.adminBadge")}</Badge>
|
||||
) : (
|
||||
t("admin.regularUser")
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
{t("admin.userId")}
|
||||
</Label>
|
||||
<p className="font-mono text-xs truncate">{user.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
{t("admin.adminPrivileges")}
|
||||
</Label>
|
||||
<div className="flex items-center justify-between p-3 border border-edge rounded-lg bg-surface">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{t("admin.administratorRole")}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.administratorRoleDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isAdmin}
|
||||
onCheckedChange={handleToggleAdmin}
|
||||
disabled={isCurrentUser || adminLoading}
|
||||
/>
|
||||
</div>
|
||||
{isCurrentUser && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.cannotModifyOwnAdminStatus")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-semibold flex items-center gap-2">
|
||||
<UserCog className="h-4 w-4" />
|
||||
{t("rbac.roleManagement")}
|
||||
</Label>
|
||||
|
||||
{rolesLoading ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("rbac.currentRoles")}
|
||||
</Label>
|
||||
{userRoles.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic py-2">
|
||||
{t("rbac.noRolesAssigned")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{userRoles.map((role) => (
|
||||
<div
|
||||
key={role.roleId}
|
||||
className="flex items-center justify-between p-3 border border-edge rounded-lg bg-surface"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{t(role.roleDisplayName)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{role.roleName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{role.isSystem && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("rbac.systemRole")}
|
||||
</Badge>
|
||||
)}
|
||||
{!role.isSystem && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveRole(role.roleId)}
|
||||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("rbac.assignNewRole")}
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableRoles
|
||||
.filter(
|
||||
(role) =>
|
||||
!role.isSystem &&
|
||||
!userRoles.some((ur) => ur.roleId === role.id),
|
||||
)
|
||||
.map((role) => (
|
||||
<Button
|
||||
key={role.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAssignRole(role.id)}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{t(role.displayName)}
|
||||
</Button>
|
||||
))}
|
||||
{availableRoles.filter(
|
||||
(role) =>
|
||||
!role.isSystem &&
|
||||
!userRoles.some((ur) => ur.roleId === role.id),
|
||||
).length === 0 && (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
{t("rbac.noCustomRolesToAssign")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
{t("admin.sessionManagement")}
|
||||
</Label>
|
||||
<div className="flex items-center justify-between p-3 border border-edge rounded-lg bg-surface">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">
|
||||
{t("admin.revokeAllSessions")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.revokeAllSessionsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleRevokeAllSessions}
|
||||
disabled={sessionLoading}
|
||||
>
|
||||
{sessionLoading ? t("admin.revoking") : t("admin.revoke")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-destructive flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{t("admin.dangerZone")}
|
||||
</Label>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("admin.deleteUserTitle")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("admin.deleteUserWarning")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteUser}
|
||||
disabled={isCurrentUser || deleteLoading}
|
||||
className="w-full"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{deleteLoading
|
||||
? t("admin.deleting")
|
||||
: `${t("common.delete")} ${user.username}`}
|
||||
</Button>
|
||||
{isCurrentUser && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t("admin.cannotDeleteSelf")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/table.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog.tsx";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/popover.tsx";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/command.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { Badge } from "@/components/badge.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import {
|
||||
Key,
|
||||
Plus,
|
||||
Trash2,
|
||||
Copy,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
getApiKeys,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
getUserList,
|
||||
type ApiKey,
|
||||
type CreatedApiKey,
|
||||
} from "@/main-axios.ts";
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
function UserCombobox({
|
||||
users,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
users: UserOption[];
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const selected = users.find((u) => u.id === value);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between font-normal"
|
||||
disabled={disabled}
|
||||
>
|
||||
{selected ? selected.username : t("admin.apiKeys.selectUser")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("admin.apiKeys.searchUsers")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("admin.apiKeys.noUsersFound")}</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-y-auto thin-scrollbar">
|
||||
{users.map((user) => (
|
||||
<CommandItem
|
||||
key={user.id}
|
||||
value={user.username}
|
||||
onSelect={() => {
|
||||
onChange(user.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === user.id ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{user.username}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateApiKeyDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState("");
|
||||
const [selectedUserId, setSelectedUserId] = React.useState("");
|
||||
const [expiresAt, setExpiresAt] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [usersLoading, setUsersLoading] = React.useState(false);
|
||||
const [users, setUsers] = React.useState<UserOption[]>([]);
|
||||
const [createdKey, setCreatedKey] = React.useState<CreatedApiKey | null>(
|
||||
null,
|
||||
);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
if (createdKey) return;
|
||||
|
||||
setUsersLoading(true);
|
||||
getUserList()
|
||||
.then((res) =>
|
||||
setUsers(
|
||||
res.users.map((u) => ({
|
||||
id: (u as unknown as { id: string }).id,
|
||||
username: u.username,
|
||||
})),
|
||||
),
|
||||
)
|
||||
.catch(() => toast.error(t("admin.failedToFetchUsers")))
|
||||
.finally(() => setUsersLoading(false));
|
||||
}, [open]);
|
||||
|
||||
const handleClose = () => {
|
||||
setCreatedKey(null);
|
||||
setName("");
|
||||
setSelectedUserId("");
|
||||
setExpiresAt("");
|
||||
setCopied(false);
|
||||
onOpenChange(false);
|
||||
onCreated();
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error(t("admin.apiKeys.nameRequired"));
|
||||
return;
|
||||
}
|
||||
if (!selectedUserId) {
|
||||
toast.error(t("admin.apiKeys.userRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await createApiKey(
|
||||
name.trim(),
|
||||
selectedUserId,
|
||||
expiresAt || undefined,
|
||||
);
|
||||
setCreatedKey(result);
|
||||
} catch (err: unknown) {
|
||||
const e = err as { response?: { data?: { error?: string } } };
|
||||
toast.error(
|
||||
e?.response?.data?.error || t("admin.apiKeys.failedToCreate"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!createdKey) return;
|
||||
await navigator.clipboard.writeText(createdKey.token);
|
||||
setCopied(true);
|
||||
toast.success(t("admin.apiKeys.tokenCopied"));
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) handleClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Key className="w-5 h-5" />
|
||||
{createdKey
|
||||
? t("admin.apiKeys.keyCreated")
|
||||
: t("admin.apiKeys.createApiKey")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{createdKey
|
||||
? t("admin.apiKeys.keyCreatedDescription")
|
||||
: t("admin.apiKeys.createApiKeyDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!createdKey ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("admin.apiKeys.keyName")}</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("admin.apiKeys.keyNamePlaceholder")}
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("admin.apiKeys.scopedUser")}</Label>
|
||||
{usersLoading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.loading")}
|
||||
</p>
|
||||
) : (
|
||||
<UserCombobox
|
||||
users={users}
|
||||
value={selectedUserId}
|
||||
onChange={setSelectedUserId}
|
||||
disabled={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("admin.apiKeys.expiresAt")}{" "}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
({t("admin.apiKeys.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={expiresAt}
|
||||
onChange={(e) => setExpiresAt(e.target.value)}
|
||||
disabled={loading}
|
||||
min={new Date().toISOString().split("T")[0]}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.apiKeys.expiresAtHelp")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 py-4">
|
||||
<Alert className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20 text-yellow-900 dark:text-yellow-200">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
|
||||
<AlertTitle>{t("admin.apiKeys.copyWarningTitle")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("admin.apiKeys.copyWarningDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("admin.apiKeys.apiKey")}</Label>
|
||||
<div className="flex gap-2 items-start">
|
||||
<code className="flex-1 block rounded bg-muted px-3 py-2 text-xs font-mono break-all border border-edge">
|
||||
{createdKey.token}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
className="shrink-0"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{!createdKey ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={loading || usersLoading}>
|
||||
{loading
|
||||
? t("admin.apiKeys.creating")
|
||||
: t("admin.apiKeys.createApiKey")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={handleClose}>{t("common.done")}</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApiKeysTab(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const [keys, setKeys] = React.useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [createDialogOpen, setCreateDialogOpen] = React.useState(false);
|
||||
|
||||
const fetchKeys = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getApiKeys();
|
||||
setKeys(data.apiKeys);
|
||||
} catch {
|
||||
toast.error(t("admin.apiKeys.failedToFetch"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchKeys();
|
||||
}, [fetchKeys]);
|
||||
|
||||
const handleDelete = (keyId: string, keyName: string) => {
|
||||
confirmWithToast(
|
||||
t("admin.apiKeys.confirmRevoke", { name: keyName }),
|
||||
async () => {
|
||||
try {
|
||||
await deleteApiKey(keyId);
|
||||
toast.success(t("admin.apiKeys.revokedSuccessfully"));
|
||||
fetchKeys();
|
||||
} catch {
|
||||
toast.error(t("admin.apiKeys.failedToRevoke"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const formatDate = (iso: string | null) => {
|
||||
if (!iso) return t("admin.apiKeys.never");
|
||||
const d = new Date(iso);
|
||||
return (
|
||||
d.toLocaleDateString() +
|
||||
" " +
|
||||
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
);
|
||||
};
|
||||
|
||||
const isExpired = (expiresAt: string | null) =>
|
||||
expiresAt ? new Date(expiresAt) < new Date() : false;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">{t("admin.apiKeys.title")}</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() =>
|
||||
window.open("https://docs.termix.site/api-keys", "_blank")
|
||||
}
|
||||
>
|
||||
{t("common.documentation")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={fetchKeys}
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("h-4 w-4 mr-1", loading && "animate-spin")}
|
||||
/>
|
||||
{loading ? t("admin.loading") : t("admin.refresh")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t("admin.apiKeys.createApiKey")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && keys.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("admin.loading")}
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("admin.apiKeys.noKeys")}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("admin.apiKeys.name")}</TableHead>
|
||||
<TableHead>{t("admin.user")}</TableHead>
|
||||
<TableHead>{t("admin.apiKeys.prefix")}</TableHead>
|
||||
<TableHead>{t("admin.created")}</TableHead>
|
||||
<TableHead>{t("admin.expires")}</TableHead>
|
||||
<TableHead>{t("admin.apiKeys.lastUsed")}</TableHead>
|
||||
<TableHead>{t("admin.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{keys.map((key) => (
|
||||
<TableRow key={key.id}>
|
||||
<TableCell className="px-4 font-medium">
|
||||
{key.name}
|
||||
{!key.isActive && (
|
||||
<Badge variant="destructive" className="ml-2 text-xs">
|
||||
{t("admin.revoked")}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
{key.username || key.userId}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded">
|
||||
{key.tokenPrefix}…
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(key.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm">
|
||||
<span
|
||||
className={
|
||||
isExpired(key.expiresAt)
|
||||
? "text-red-500"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{formatDate(key.expiresAt)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(key.lastUsedAt)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(key.id, key.name)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
title={t("admin.apiKeys.revokeKey")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<CreateApiKeyDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onCreated={fetchKeys}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Download, Upload } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { isElectron } from "@/main-axios.ts";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
|
||||
export function DatabaseSecurityTab(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [exportLoading, setExportLoading] = React.useState(false);
|
||||
const [importLoading, setImportLoading] = React.useState(false);
|
||||
const [importFile, setImportFile] = React.useState<File | null>(null);
|
||||
|
||||
const handleExportDatabase = async () => {
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const isDev =
|
||||
!isElectron() &&
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "" ||
|
||||
window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1");
|
||||
|
||||
const apiUrl = isElectron()
|
||||
? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/database/export`
|
||||
: isDev
|
||||
? `http://localhost:30001/database/export`
|
||||
: `${window.location.protocol}//${window.location.host}${getBasePath()}/database/export`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
credentials: "include",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const contentDisposition = response.headers.get("content-disposition");
|
||||
const filename =
|
||||
contentDisposition?.match(/filename="([^"]+)"/)?.[1] ||
|
||||
"termix-export.sqlite";
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
toast.success(t("admin.databaseExportedSuccessfully"));
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast.error(error.error || t("admin.databaseExportFailed"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.databaseExportFailed"));
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportDatabase = async () => {
|
||||
if (!importFile) {
|
||||
toast.error(t("admin.pleaseSelectImportFile"));
|
||||
return;
|
||||
}
|
||||
|
||||
setImportLoading(true);
|
||||
try {
|
||||
const isDev =
|
||||
!isElectron() &&
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "" ||
|
||||
window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1");
|
||||
|
||||
const apiUrl = isElectron()
|
||||
? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/database/import`
|
||||
: isDev
|
||||
? `http://localhost:30001/database/import`
|
||||
: `${window.location.protocol}//${window.location.host}${getBasePath()}/database/import`;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", importFile);
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const summary = result.summary;
|
||||
const imported =
|
||||
summary.sshHostsImported +
|
||||
summary.sshCredentialsImported +
|
||||
summary.fileManagerItemsImported +
|
||||
summary.dismissedAlertsImported +
|
||||
(summary.settingsImported || 0);
|
||||
const skipped = summary.skippedItems;
|
||||
|
||||
const details = [];
|
||||
if (summary.sshHostsImported > 0)
|
||||
details.push(`${summary.sshHostsImported} SSH hosts`);
|
||||
if (summary.sshCredentialsImported > 0)
|
||||
details.push(`${summary.sshCredentialsImported} credentials`);
|
||||
if (summary.fileManagerItemsImported > 0)
|
||||
details.push(
|
||||
`${summary.fileManagerItemsImported} file manager items`,
|
||||
);
|
||||
if (summary.dismissedAlertsImported > 0)
|
||||
details.push(`${summary.dismissedAlertsImported} alerts`);
|
||||
if (summary.settingsImported > 0)
|
||||
details.push(`${summary.settingsImported} settings`);
|
||||
|
||||
toast.success(
|
||||
`Import completed: ${imported} items imported${details.length > 0 ? ` (${details.join(", ")})` : ""}, ${skipped} items skipped`,
|
||||
);
|
||||
setImportFile(null);
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
toast.error(
|
||||
`${t("admin.databaseImportFailed")}: ${result.summary?.errors?.join(", ") || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast.error(error.error || t("admin.databaseImportFailed"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.databaseImportFailed"));
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t("admin.databaseSecurity")}</h3>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="p-4 border rounded-lg bg-surface">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4 text-blue-500" />
|
||||
<h4 className="font-semibold">{t("admin.export")}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.exportDescription")}
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleExportDatabase}
|
||||
disabled={exportLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{exportLoading ? t("admin.exporting") : t("admin.export")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg bg-surface">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 text-green-500" />
|
||||
<h4 className="font-semibold">{t("admin.import")}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.importDescription")}
|
||||
</p>
|
||||
<div className="relative inline-block w-full mb-2">
|
||||
<input
|
||||
id="import-file-upload"
|
||||
type="file"
|
||||
accept=".sqlite,.db"
|
||||
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left"
|
||||
>
|
||||
<span
|
||||
className="truncate"
|
||||
title={importFile?.name || t("admin.pleaseSelectImportFile")}
|
||||
>
|
||||
{importFile
|
||||
? importFile.name
|
||||
: t("admin.pleaseSelectImportFile")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleImportDatabase}
|
||||
disabled={importLoading || !importFile}
|
||||
className="w-full"
|
||||
>
|
||||
{importLoading ? t("admin.importing") : t("admin.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,531 +0,0 @@
|
||||
import React from "react";
|
||||
import { Checkbox } from "@/components/checkbox.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
updateRegistrationAllowed,
|
||||
updatePasswordLoginAllowed,
|
||||
updatePasswordResetAllowed,
|
||||
getGlobalMonitoringSettings,
|
||||
updateGlobalMonitoringSettings,
|
||||
getGuacamoleSettings,
|
||||
updateGuacamoleSettings,
|
||||
getLogLevel,
|
||||
updateLogLevel,
|
||||
getSessionTimeout,
|
||||
updateSessionTimeout,
|
||||
} from "@/main-axios.ts";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
|
||||
interface GeneralSettingsTabProps {
|
||||
allowRegistration: boolean;
|
||||
setAllowRegistration: (value: boolean) => void;
|
||||
allowPasswordLogin: boolean;
|
||||
setAllowPasswordLogin: (value: boolean) => void;
|
||||
allowPasswordReset: boolean;
|
||||
setAllowPasswordReset: (value: boolean) => void;
|
||||
oidcConfig: {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
issuer_url: string;
|
||||
authorization_url: string;
|
||||
token_url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function GeneralSettingsTab({
|
||||
allowRegistration,
|
||||
setAllowRegistration,
|
||||
allowPasswordLogin,
|
||||
setAllowPasswordLogin,
|
||||
allowPasswordReset,
|
||||
setAllowPasswordReset,
|
||||
oidcConfig,
|
||||
}: GeneralSettingsTabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const [regLoading, setRegLoading] = React.useState(false);
|
||||
const [passwordLoginLoading, setPasswordLoginLoading] = React.useState(false);
|
||||
const [passwordResetLoading, setPasswordResetLoading] = React.useState(false);
|
||||
|
||||
const [statusInterval, setStatusInterval] = React.useState(60);
|
||||
const [metricsInterval, setMetricsInterval] = React.useState(30);
|
||||
const [statusInputValue, setStatusInputValue] = React.useState("60");
|
||||
const [metricsInputValue, setMetricsInputValue] = React.useState("30");
|
||||
const [statusUnit, setStatusUnit] = React.useState<"seconds" | "minutes">(
|
||||
"seconds",
|
||||
);
|
||||
const [metricsUnit, setMetricsUnit] = React.useState<"seconds" | "minutes">(
|
||||
"seconds",
|
||||
);
|
||||
const [monitoringLoading, setMonitoringLoading] = React.useState(false);
|
||||
|
||||
const [logLevel, setLogLevel] = React.useState("info");
|
||||
const [logLevelLoading, setLogLevelLoading] = React.useState(false);
|
||||
|
||||
const [, setSessionTimeoutHours] = React.useState(24);
|
||||
const [sessionTimeoutInput, setSessionTimeoutInput] = React.useState("24");
|
||||
const [sessionTimeoutLoading, setSessionTimeoutLoading] =
|
||||
React.useState(false);
|
||||
|
||||
const [guacEnabled, setGuacEnabled] = React.useState(true);
|
||||
const [guacUrl, setGuacUrl] = React.useState("guacd:4822");
|
||||
const [guacLoading, setGuacLoading] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
getLogLevel()
|
||||
.then((data) => {
|
||||
setLogLevel(data.level);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
getSessionTimeout()
|
||||
.then((data) => {
|
||||
setSessionTimeoutHours(data.timeoutHours);
|
||||
setSessionTimeoutInput(String(data.timeoutHours));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleLogLevelChange = async (value: string) => {
|
||||
setLogLevel(value);
|
||||
setLogLevelLoading(true);
|
||||
try {
|
||||
await updateLogLevel(value);
|
||||
toast.success(t("admin.logLevelSaved"));
|
||||
} catch {
|
||||
toast.error(t("admin.failedToSaveLogLevel"));
|
||||
} finally {
|
||||
setLogLevelLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSessionTimeoutBlur = async () => {
|
||||
const num = parseInt(sessionTimeoutInput) || 24;
|
||||
const clamped = Math.max(1, Math.min(720, num));
|
||||
setSessionTimeoutHours(clamped);
|
||||
setSessionTimeoutInput(String(clamped));
|
||||
setSessionTimeoutLoading(true);
|
||||
try {
|
||||
await updateSessionTimeout(clamped);
|
||||
toast.success(t("admin.sessionTimeoutSaved"));
|
||||
} catch {
|
||||
toast.error(t("admin.failedToSaveSessionTimeout"));
|
||||
} finally {
|
||||
setSessionTimeoutLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
getGuacamoleSettings()
|
||||
.then((data) => {
|
||||
setGuacEnabled(data.enabled);
|
||||
setGuacUrl(data.url);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t("admin.failedToLoadGuacamoleSettings"));
|
||||
});
|
||||
}, [t]);
|
||||
|
||||
const saveGuacDebounce = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const saveGuacSettings = React.useCallback(
|
||||
(newEnabled: boolean, newUrl: string) => {
|
||||
if (saveGuacDebounce.current) {
|
||||
clearTimeout(saveGuacDebounce.current);
|
||||
}
|
||||
saveGuacDebounce.current = setTimeout(async () => {
|
||||
setGuacLoading(true);
|
||||
try {
|
||||
await updateGuacamoleSettings({ enabled: newEnabled, url: newUrl });
|
||||
toast.success(t("admin.guacamoleSettingsSaved"));
|
||||
} catch {
|
||||
toast.error(t("admin.failedToSaveGuacamoleSettings"));
|
||||
} finally {
|
||||
setGuacLoading(false);
|
||||
}
|
||||
}, 800);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
getGlobalMonitoringSettings()
|
||||
.then((data) => {
|
||||
setStatusInterval(data.statusCheckInterval);
|
||||
setMetricsInterval(data.metricsInterval);
|
||||
setStatusInputValue(String(data.statusCheckInterval));
|
||||
setMetricsInputValue(String(data.metricsInterval));
|
||||
})
|
||||
.catch(() => {
|
||||
// Use defaults silently
|
||||
});
|
||||
}, []);
|
||||
|
||||
const saveMonitoringSettings = React.useCallback(
|
||||
async (newStatus: number, newMetrics: number) => {
|
||||
setMonitoringLoading(true);
|
||||
try {
|
||||
await updateGlobalMonitoringSettings({
|
||||
statusCheckInterval: newStatus,
|
||||
metricsInterval: newMetrics,
|
||||
});
|
||||
toast.success(t("admin.globalSettingsSaved"));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("admin.failedToSaveGlobalSettings");
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setMonitoringLoading(false);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleStatusBlur = () => {
|
||||
const num = parseInt(statusInputValue) || 0;
|
||||
const seconds = statusUnit === "minutes" ? num * 60 : num;
|
||||
const clamped = Math.max(5, Math.min(3600, seconds));
|
||||
setStatusInterval(clamped);
|
||||
setStatusInputValue(
|
||||
statusUnit === "minutes"
|
||||
? String(Math.round(clamped / 60))
|
||||
: String(clamped),
|
||||
);
|
||||
saveMonitoringSettings(clamped, metricsInterval);
|
||||
};
|
||||
|
||||
const handleMetricsBlur = () => {
|
||||
const num = parseInt(metricsInputValue) || 0;
|
||||
const seconds = metricsUnit === "minutes" ? num * 60 : num;
|
||||
const clamped = Math.max(5, Math.min(3600, seconds));
|
||||
setMetricsInterval(clamped);
|
||||
setMetricsInputValue(
|
||||
metricsUnit === "minutes"
|
||||
? String(Math.round(clamped / 60))
|
||||
: String(clamped),
|
||||
);
|
||||
saveMonitoringSettings(statusInterval, clamped);
|
||||
};
|
||||
|
||||
const handleToggleRegistration = async (checked: boolean) => {
|
||||
setRegLoading(true);
|
||||
try {
|
||||
await updateRegistrationAllowed(checked);
|
||||
setAllowRegistration(checked);
|
||||
} finally {
|
||||
setRegLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePasswordLogin = async (checked: boolean) => {
|
||||
if (!checked) {
|
||||
const hasOIDCConfigured =
|
||||
oidcConfig.client_id &&
|
||||
oidcConfig.client_secret &&
|
||||
oidcConfig.issuer_url &&
|
||||
oidcConfig.authorization_url &&
|
||||
oidcConfig.token_url;
|
||||
|
||||
if (!hasOIDCConfigured) {
|
||||
toast.error(t("admin.cannotDisablePasswordLoginWithoutOIDC"), {
|
||||
duration: 5000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
confirmWithToast(
|
||||
t("admin.confirmDisablePasswordLogin"),
|
||||
async () => {
|
||||
setPasswordLoginLoading(true);
|
||||
try {
|
||||
await updatePasswordLoginAllowed(checked);
|
||||
setAllowPasswordLogin(checked);
|
||||
|
||||
if (allowRegistration) {
|
||||
await updateRegistrationAllowed(false);
|
||||
setAllowRegistration(false);
|
||||
toast.success(t("admin.passwordLoginAndRegistrationDisabled"));
|
||||
} else {
|
||||
toast.success(t("admin.passwordLoginDisabled"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.failedToUpdatePasswordLoginStatus"));
|
||||
} finally {
|
||||
setPasswordLoginLoading(false);
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setPasswordLoginLoading(true);
|
||||
try {
|
||||
await updatePasswordLoginAllowed(checked);
|
||||
setAllowPasswordLogin(checked);
|
||||
} finally {
|
||||
setPasswordLoginLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePasswordReset = async (checked: boolean) => {
|
||||
setPasswordResetLoading(true);
|
||||
try {
|
||||
await updatePasswordResetAllowed(checked);
|
||||
setAllowPasswordReset(checked);
|
||||
} finally {
|
||||
setPasswordResetLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t("admin.userRegistration")}</h3>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={allowRegistration}
|
||||
onCheckedChange={handleToggleRegistration}
|
||||
disabled={regLoading || !allowPasswordLogin}
|
||||
/>
|
||||
{t("admin.allowNewAccountRegistration")}
|
||||
{!allowPasswordLogin && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({t("admin.requiresPasswordLogin")})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={allowPasswordLogin}
|
||||
onCheckedChange={handleTogglePasswordLogin}
|
||||
disabled={passwordLoginLoading}
|
||||
/>
|
||||
{t("admin.allowPasswordLogin")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={allowPasswordReset}
|
||||
onCheckedChange={handleTogglePasswordReset}
|
||||
disabled={passwordResetLoading || !allowPasswordLogin}
|
||||
/>
|
||||
{t("admin.allowPasswordReset")}
|
||||
{!allowPasswordLogin && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({t("admin.requiresPasswordLogin")})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t("admin.sessionTimeout")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.sessionTimeoutDesc")}
|
||||
</p>
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
{t("admin.sessionTimeoutHours")}
|
||||
</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={720}
|
||||
value={sessionTimeoutInput}
|
||||
onChange={(e) => setSessionTimeoutInput(e.target.value)}
|
||||
onBlur={handleSessionTimeoutBlur}
|
||||
disabled={sessionTimeoutLoading}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-sm font-medium py-2">{t("admin.hours")}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("admin.sessionTimeoutNote")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("admin.monitoringDefaults")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.monitoringDefaultsDesc")}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
{t("admin.globalStatusCheckInterval")}
|
||||
</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={statusInputValue}
|
||||
onChange={(e) => setStatusInputValue(e.target.value)}
|
||||
onBlur={handleStatusBlur}
|
||||
disabled={monitoringLoading}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select
|
||||
value={statusUnit}
|
||||
onValueChange={(value: "seconds" | "minutes") => {
|
||||
setStatusUnit(value);
|
||||
setStatusInputValue(
|
||||
value === "minutes"
|
||||
? String(Math.round(statusInterval / 60))
|
||||
: String(statusInterval),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="seconds">
|
||||
{t("hosts.intervalSeconds")}
|
||||
</SelectItem>
|
||||
<SelectItem value="minutes">
|
||||
{t("hosts.intervalMinutes")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
{t("admin.globalMetricsInterval")}
|
||||
</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={metricsInputValue}
|
||||
onChange={(e) => setMetricsInputValue(e.target.value)}
|
||||
onBlur={handleMetricsBlur}
|
||||
disabled={monitoringLoading}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select
|
||||
value={metricsUnit}
|
||||
onValueChange={(value: "seconds" | "minutes") => {
|
||||
setMetricsUnit(value);
|
||||
setMetricsInputValue(
|
||||
value === "minutes"
|
||||
? String(Math.round(metricsInterval / 60))
|
||||
: String(metricsInterval),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="seconds">
|
||||
{t("hosts.intervalSeconds")}
|
||||
</SelectItem>
|
||||
<SelectItem value="minutes">
|
||||
{t("hosts.intervalMinutes")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("admin.guacamoleIntegration")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.guacamoleIntegrationDesc")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() =>
|
||||
window.open("https://docs.termix.site/remote-desktop", "_blank")
|
||||
}
|
||||
>
|
||||
{t("common.documentation")}
|
||||
</Button>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={guacEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
const val = checked === true;
|
||||
setGuacEnabled(val);
|
||||
saveGuacSettings(val, guacUrl);
|
||||
}}
|
||||
disabled={guacLoading}
|
||||
/>
|
||||
{t("admin.enableGuacamole")}
|
||||
</label>
|
||||
{guacEnabled && (
|
||||
<div>
|
||||
<label className="text-sm font-medium">{t("admin.guacdUrl")}</label>
|
||||
<Input
|
||||
className="mt-1"
|
||||
value={guacUrl}
|
||||
placeholder={t("admin.guacdUrlPlaceholder")}
|
||||
disabled={guacLoading}
|
||||
onChange={(e) => {
|
||||
setGuacUrl(e.target.value);
|
||||
saveGuacSettings(guacEnabled, e.target.value);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("admin.guacdUrlNote")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t("admin.logLevel")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.logLevelDesc")}
|
||||
</p>
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
{t("admin.logVerbosity")}
|
||||
</label>
|
||||
<Select
|
||||
value={logLevel}
|
||||
onValueChange={handleLogLevelChange}
|
||||
disabled={logLevelLoading}
|
||||
>
|
||||
<SelectTrigger className="w-[200px] mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="debug">Debug</SelectItem>
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
<SelectItem value="warn">Warning</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("admin.logLevelNote")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { PasswordInput } from "@/components/password-input.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import { Textarea } from "@/components/textarea.tsx";
|
||||
import { updateOIDCConfig, disableOIDCConfig } from "@/main-axios.ts";
|
||||
|
||||
interface OIDCSettingsTabProps {
|
||||
allowPasswordLogin: boolean;
|
||||
oidcConfig: {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
issuer_url: string;
|
||||
authorization_url: string;
|
||||
token_url: string;
|
||||
identifier_path: string;
|
||||
name_path: string;
|
||||
scopes: string;
|
||||
userinfo_url: string;
|
||||
allowed_users: string;
|
||||
};
|
||||
setOidcConfig: React.Dispatch<
|
||||
React.SetStateAction<{
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
issuer_url: string;
|
||||
authorization_url: string;
|
||||
token_url: string;
|
||||
identifier_path: string;
|
||||
name_path: string;
|
||||
scopes: string;
|
||||
userinfo_url: string;
|
||||
allowed_users: string;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export function OIDCSettingsTab({
|
||||
allowPasswordLogin,
|
||||
oidcConfig,
|
||||
setOidcConfig,
|
||||
}: OIDCSettingsTabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const [oidcLoading, setOidcLoading] = React.useState(false);
|
||||
const [oidcError, setOidcError] = React.useState<string | null>(null);
|
||||
|
||||
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setOidcLoading(true);
|
||||
setOidcError(null);
|
||||
|
||||
const required = [
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"issuer_url",
|
||||
"authorization_url",
|
||||
"token_url",
|
||||
];
|
||||
const missing = required.filter(
|
||||
(f) => !oidcConfig[f as keyof typeof oidcConfig],
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
setOidcError(
|
||||
t("admin.missingRequiredFields", { fields: missing.join(", ") }),
|
||||
);
|
||||
setOidcLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateOIDCConfig(oidcConfig);
|
||||
toast.success(t("admin.oidcConfigurationUpdated"));
|
||||
} catch (err: unknown) {
|
||||
setOidcError(
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data
|
||||
?.error || t("admin.failedToUpdateOidcConfig"),
|
||||
);
|
||||
} finally {
|
||||
setOidcLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOIDCConfigChange = (field: string, value: string) => {
|
||||
setOidcConfig((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleResetConfig = async () => {
|
||||
if (!allowPasswordLogin) {
|
||||
confirmWithToast(
|
||||
t("admin.confirmDisableOIDCWarning"),
|
||||
async () => {
|
||||
const emptyConfig = {
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
issuer_url: "",
|
||||
authorization_url: "",
|
||||
token_url: "",
|
||||
identifier_path: "",
|
||||
name_path: "",
|
||||
scopes: "",
|
||||
userinfo_url: "",
|
||||
allowed_users: "",
|
||||
};
|
||||
setOidcConfig(emptyConfig);
|
||||
setOidcError(null);
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
await disableOIDCConfig();
|
||||
toast.success(t("admin.oidcConfigurationDisabled"));
|
||||
} catch (err: unknown) {
|
||||
setOidcError(
|
||||
(
|
||||
err as {
|
||||
response?: { data?: { error?: string } };
|
||||
}
|
||||
)?.response?.data?.error || t("admin.failedToDisableOidcConfig"),
|
||||
);
|
||||
} finally {
|
||||
setOidcLoading(false);
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const emptyConfig = {
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
issuer_url: "",
|
||||
authorization_url: "",
|
||||
token_url: "",
|
||||
identifier_path: "",
|
||||
name_path: "",
|
||||
scopes: "",
|
||||
userinfo_url: "",
|
||||
allowed_users: "",
|
||||
};
|
||||
setOidcConfig(emptyConfig);
|
||||
setOidcError(null);
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
await disableOIDCConfig();
|
||||
toast.success(t("admin.oidcConfigurationDisabled"));
|
||||
} catch (err: unknown) {
|
||||
setOidcError(
|
||||
(
|
||||
err as {
|
||||
response?: { data?: { error?: string } };
|
||||
}
|
||||
)?.response?.data?.error || t("admin.failedToDisableOidcConfig"),
|
||||
);
|
||||
} finally {
|
||||
setOidcLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-3">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("admin.externalAuthentication")}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.configureExternalProvider")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => window.open("https://docs.termix.site/oidc", "_blank")}
|
||||
>
|
||||
{t("common.documentation")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!allowPasswordLogin && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("admin.criticalWarning")}</AlertTitle>
|
||||
<AlertDescription>{t("admin.oidcRequiredWarning")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{oidcError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
<AlertDescription>{oidcError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_id">{t("admin.clientId")}</Label>
|
||||
<Input
|
||||
id="client_id"
|
||||
value={oidcConfig.client_id}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("client_id", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.clientId")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_secret">{t("admin.clientSecret")}</Label>
|
||||
<PasswordInput
|
||||
id="client_secret"
|
||||
value={oidcConfig.client_secret}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("client_secret", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.clientSecret")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="authorization_url">
|
||||
{t("admin.authorizationUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
id="authorization_url"
|
||||
value={oidcConfig.authorization_url}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("authorization_url", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.authUrl")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issuer_url">{t("admin.issuerUrl")}</Label>
|
||||
<Input
|
||||
id="issuer_url"
|
||||
value={oidcConfig.issuer_url}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("issuer_url", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.redirectUrl")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token_url">{t("admin.tokenUrl")}</Label>
|
||||
<Input
|
||||
id="token_url"
|
||||
value={oidcConfig.token_url}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("token_url", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.tokenUrl")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="identifier_path">
|
||||
{t("admin.userIdentifierPath")}
|
||||
</Label>
|
||||
<Input
|
||||
id="identifier_path"
|
||||
value={oidcConfig.identifier_path}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("identifier_path", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.userIdField")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name_path">{t("admin.displayNamePath")}</Label>
|
||||
<Input
|
||||
id="name_path"
|
||||
value={oidcConfig.name_path}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("name_path", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.usernameField")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scopes">{t("admin.scopes")}</Label>
|
||||
<Input
|
||||
id="scopes"
|
||||
value={oidcConfig.scopes}
|
||||
onChange={(e) => handleOIDCConfigChange("scopes", e.target.value)}
|
||||
placeholder={t("placeholders.scopes")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="userinfo_url">{t("admin.overrideUserInfoUrl")}</Label>
|
||||
<Input
|
||||
id="userinfo_url"
|
||||
value={oidcConfig.userinfo_url}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("userinfo_url", e.target.value)
|
||||
}
|
||||
placeholder="https://your-provider.com/application/o/userinfo/"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="allowed_users">{t("admin.allowedUsers")}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.allowedUsersDescription")}
|
||||
</p>
|
||||
<Textarea
|
||||
id="allowed_users"
|
||||
value={oidcConfig.allowed_users}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("allowed_users", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.allowedUsers")}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit" className="flex-1" disabled={oidcLoading}>
|
||||
{oidcLoading ? t("admin.saving") : t("admin.saveConfiguration")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleResetConfig}
|
||||
disabled={oidcLoading}
|
||||
>
|
||||
{t("admin.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog.tsx";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/table.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { Textarea } from "@/components/textarea.tsx";
|
||||
import { Badge } from "@/components/badge.tsx";
|
||||
import { Shield, Plus, Edit, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
getRoles,
|
||||
createRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
type Role,
|
||||
} from "@/main-axios.ts";
|
||||
|
||||
export function RolesTab(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const [roles, setRoles] = React.useState<Role[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const [roleDialogOpen, setRoleDialogOpen] = React.useState(false);
|
||||
const [editingRole, setEditingRole] = React.useState<Role | null>(null);
|
||||
const [roleName, setRoleName] = React.useState("");
|
||||
const [roleDisplayName, setRoleDisplayName] = React.useState("");
|
||||
const [roleDescription, setRoleDescription] = React.useState("");
|
||||
|
||||
const loadRoles = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getRoles();
|
||||
setRoles(response.roles || []);
|
||||
} catch (error) {
|
||||
toast.error(t("rbac.failedToLoadRoles"));
|
||||
console.error("Failed to load roles:", error);
|
||||
setRoles([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadRoles();
|
||||
}, [loadRoles]);
|
||||
|
||||
const handleCreateRole = () => {
|
||||
setEditingRole(null);
|
||||
setRoleName("");
|
||||
setRoleDisplayName("");
|
||||
setRoleDescription("");
|
||||
setRoleDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditRole = (role: Role) => {
|
||||
setEditingRole(role);
|
||||
setRoleName(role.name);
|
||||
setRoleDisplayName(role.displayName);
|
||||
setRoleDescription(role.description || "");
|
||||
setRoleDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveRole = async () => {
|
||||
if (!roleDisplayName.trim()) {
|
||||
toast.error(t("rbac.roleDisplayNameRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingRole && !roleName.trim()) {
|
||||
toast.error(t("rbac.roleNameRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingRole) {
|
||||
await updateRole(editingRole.id, {
|
||||
displayName: roleDisplayName,
|
||||
description: roleDescription || null,
|
||||
});
|
||||
toast.success(t("rbac.roleUpdatedSuccessfully"));
|
||||
} else {
|
||||
await createRole({
|
||||
name: roleName,
|
||||
displayName: roleDisplayName,
|
||||
description: roleDescription || null,
|
||||
});
|
||||
toast.success(t("rbac.roleCreatedSuccessfully"));
|
||||
}
|
||||
|
||||
setRoleDialogOpen(false);
|
||||
loadRoles();
|
||||
} catch {
|
||||
toast.error(t("rbac.failedToSaveRole"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRole = async (role: Role) => {
|
||||
const confirmed = await confirmWithToast({
|
||||
title: t("rbac.confirmDeleteRole"),
|
||||
description: t("rbac.confirmDeleteRoleDescription", {
|
||||
name: role.displayName,
|
||||
}),
|
||||
confirmText: t("common.delete"),
|
||||
cancelText: t("common.cancel"),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await deleteRole(role.id);
|
||||
toast.success(t("rbac.roleDeletedSuccessfully"));
|
||||
loadRoles();
|
||||
} catch {
|
||||
toast.error(t("rbac.failedToDeleteRole"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
{t("rbac.roleManagement")}
|
||||
</h3>
|
||||
<Button onClick={handleCreateRole}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("rbac.createRole")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => window.open("https://docs.termix.site/rbac", "_blank")}
|
||||
>
|
||||
{t("common.documentation")}
|
||||
</Button>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("rbac.roleName")}</TableHead>
|
||||
<TableHead>{t("rbac.displayName")}</TableHead>
|
||||
<TableHead>{t("rbac.description")}</TableHead>
|
||||
<TableHead>{t("rbac.type")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("common.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("common.loading")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : roles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("rbac.noRoles")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
roles.map((role) => (
|
||||
<TableRow key={role.id}>
|
||||
<TableCell className="font-mono">{role.name}</TableCell>
|
||||
<TableCell>{t(role.displayName)}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">
|
||||
{role.description || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{role.isSystem ? (
|
||||
<Badge variant="secondary">{t("rbac.systemRole")}</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{t("rbac.customRole")}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{!role.isSystem && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleEditRole(role)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteRole(role)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingRole ? t("rbac.editRole") : t("rbac.createRole")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingRole
|
||||
? t("rbac.editRoleDescription")
|
||||
: t("rbac.createRoleDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{!editingRole && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-name">{t("rbac.roleName")}</Label>
|
||||
<Input
|
||||
id="role-name"
|
||||
value={roleName}
|
||||
onChange={(e) => setRoleName(e.target.value.toLowerCase())}
|
||||
placeholder="developer"
|
||||
disabled={!!editingRole}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("rbac.roleNameHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-display-name">{t("rbac.displayName")}</Label>
|
||||
<Input
|
||||
id="role-display-name"
|
||||
value={roleDisplayName}
|
||||
onChange={(e) => setRoleDisplayName(e.target.value)}
|
||||
placeholder={t("rbac.displayNamePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-description">{t("rbac.description")}</Label>
|
||||
<Textarea
|
||||
id="role-description"
|
||||
value={roleDescription}
|
||||
onChange={(e) => setRoleDescription(e.target.value)}
|
||||
placeholder={t("rbac.descriptionPlaceholder")}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRoleDialogOpen(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSaveRole}>
|
||||
{editingRole ? t("common.save") : t("common.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/table.tsx";
|
||||
import { Monitor, Smartphone, Globe, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import { revokeSession, revokeAllUserSessions } from "@/main-axios.ts";
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
userId: string;
|
||||
username?: string;
|
||||
deviceType: string;
|
||||
deviceInfo: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
lastActiveAt: string;
|
||||
isRevoked?: boolean;
|
||||
isCurrentSession?: boolean;
|
||||
}
|
||||
|
||||
interface SessionManagementTabProps {
|
||||
sessions: Session[];
|
||||
sessionsLoading: boolean;
|
||||
fetchSessions: () => void;
|
||||
}
|
||||
|
||||
export function SessionManagementTab({
|
||||
sessions,
|
||||
sessionsLoading,
|
||||
fetchSessions,
|
||||
}: SessionManagementTabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const handleRevokeSession = async (sessionId: string) => {
|
||||
const isCurrentSession = sessions.some(
|
||||
(session) => session.id === sessionId && session.isCurrentSession,
|
||||
);
|
||||
|
||||
confirmWithToast(
|
||||
t("admin.confirmRevokeSession"),
|
||||
async () => {
|
||||
try {
|
||||
await revokeSession(sessionId);
|
||||
toast.success(t("admin.sessionRevokedSuccessfully"));
|
||||
|
||||
if (isCurrentSession) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
fetchSessions();
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.failedToRevokeSession"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const handleRevokeAllUserSessions = async (userId: string) => {
|
||||
confirmWithToast(
|
||||
t("admin.confirmRevokeAllSessions"),
|
||||
async () => {
|
||||
try {
|
||||
const data = await revokeAllUserSessions(userId);
|
||||
toast.success(data.message || t("admin.sessionsRevokedSuccessfully"));
|
||||
fetchSessions();
|
||||
} catch {
|
||||
toast.error(t("admin.failedToRevokeSessions"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
date.toLocaleDateString() +
|
||||
" " +
|
||||
date.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("admin.sessionManagement")}
|
||||
</h3>
|
||||
<Button
|
||||
onClick={fetchSessions}
|
||||
disabled={sessionsLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{sessionsLoading ? t("admin.loading") : t("admin.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
{sessionsLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("admin.loadingSessions")}
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("admin.noActiveSessions")}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("admin.device")}</TableHead>
|
||||
<TableHead>{t("admin.user")}</TableHead>
|
||||
<TableHead>{t("admin.created")}</TableHead>
|
||||
<TableHead>{t("admin.lastActive")}</TableHead>
|
||||
<TableHead>{t("admin.expires")}</TableHead>
|
||||
<TableHead>{t("admin.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.map((session) => {
|
||||
const DeviceIcon =
|
||||
session.deviceType === "desktop"
|
||||
? Monitor
|
||||
: session.deviceType === "mobile"
|
||||
? Smartphone
|
||||
: Globe;
|
||||
|
||||
const createdDate = new Date(session.createdAt);
|
||||
const lastActiveDate = new Date(session.lastActiveAt);
|
||||
const expiresDate = new Date(session.expiresAt);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={session.id}
|
||||
className={session.isRevoked ? "opacity-50" : undefined}
|
||||
>
|
||||
<TableCell className="px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<DeviceIcon className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-sm">
|
||||
{session.deviceInfo}
|
||||
</span>
|
||||
{session.isRevoked && (
|
||||
<span className="text-xs text-red-600">
|
||||
{t("admin.revoked")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
{session.username || session.userId}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(createdDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(lastActiveDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(expiresDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRevokeSession(session.id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={session.isRevoked}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{session.username && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleRevokeAllUserSessions(session.userId)
|
||||
}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50 text-xs"
|
||||
title={t("admin.revokeAllUserSessionsTitle")}
|
||||
>
|
||||
{t("admin.revokeAll")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/table.tsx";
|
||||
import { UserPlus, Edit, Trash2, Link2, Unlink } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import { deleteUser } from "@/main-axios.ts";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash?: string;
|
||||
}
|
||||
|
||||
interface UserManagementTabProps {
|
||||
users: User[];
|
||||
usersLoading: boolean;
|
||||
allowPasswordLogin: boolean;
|
||||
fetchUsers: () => void;
|
||||
onCreateUser: () => void;
|
||||
onEditUser: (user: User) => void;
|
||||
onLinkOIDCUser: (user: { id: string; username: string }) => void;
|
||||
onUnlinkOIDC: (userId: string, username: string) => void;
|
||||
}
|
||||
|
||||
export function UserManagementTab({
|
||||
users,
|
||||
usersLoading,
|
||||
allowPasswordLogin,
|
||||
fetchUsers,
|
||||
onCreateUser,
|
||||
onEditUser,
|
||||
onLinkOIDCUser,
|
||||
onUnlinkOIDC,
|
||||
}: UserManagementTabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const getAuthTypeDisplay = (user: User): string => {
|
||||
if (user.isOidc && user.passwordHash) {
|
||||
return t("admin.dualAuth");
|
||||
} else if (user.isOidc) {
|
||||
return t("admin.externalOIDC");
|
||||
} else {
|
||||
return t("admin.localPassword");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUserQuick = async (username: string) => {
|
||||
confirmWithToast(
|
||||
t("admin.deleteUser", { username }),
|
||||
async () => {
|
||||
try {
|
||||
await deleteUser(username);
|
||||
toast.success(t("admin.userDeletedSuccessfully", { username }));
|
||||
fetchUsers();
|
||||
} catch {
|
||||
toast.error(t("admin.failedToDeleteUser"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">{t("admin.userManagement")}</h3>
|
||||
<div className="flex gap-2">
|
||||
{allowPasswordLogin && (
|
||||
<Button onClick={onCreateUser} size="sm">
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
{t("admin.createUser")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={fetchUsers}
|
||||
disabled={usersLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{usersLoading ? t("admin.loading") : t("admin.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{usersLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("admin.loadingUsers")}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("admin.username")}</TableHead>
|
||||
<TableHead>{t("admin.authType")}</TableHead>
|
||||
<TableHead>{t("admin.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">
|
||||
{user.username}
|
||||
{user.isAdmin && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||
{t("admin.adminBadge")}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{getAuthTypeDisplay(user)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEditUser(user)}
|
||||
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
||||
title={t("admin.manageUser")}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
{user.isOidc && !user.passwordHash && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onLinkOIDCUser({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
})
|
||||
}
|
||||
className="text-purple-600 hover:text-purple-700 hover:bg-purple-50"
|
||||
title="Link to password account"
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{user.isOidc && user.passwordHash && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onUnlinkOIDC(user.id, user.username)}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||
title="Unlink OIDC (keep password only)"
|
||||
>
|
||||
<Unlink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteUserQuick(user.username)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={user.isAdmin}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+606
-460
File diff suppressed because it is too large
Load Diff
@@ -125,15 +125,15 @@ export function ElectronLoginForm({
|
||||
const isEmbeddedServer = serverUrl.includes("localhost:30001");
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 w-screen h-screen bg-canvas flex flex-col">
|
||||
<div className="fixed inset-0 w-screen h-screen bg-background flex flex-col">
|
||||
{isAuthenticating && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-canvas z-50">
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background z-50">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAuthenticating && (
|
||||
<div className="flex items-center justify-between p-4 bg-canvas border-b border-edge">
|
||||
<div className="flex items-center justify-between p-4 bg-background border-b border-border">
|
||||
<button
|
||||
onClick={onChangeServer}
|
||||
className="flex items-center gap-2 text-foreground hover:text-primary transition-colors"
|
||||
@@ -143,14 +143,11 @@ export function ElectronLoginForm({
|
||||
{t("serverConfig.changeServer")}
|
||||
</span>
|
||||
</button>
|
||||
{!isEmbeddedServer && (
|
||||
<div className="flex-1 mx-4 text-center">
|
||||
<span className="text-muted-foreground text-sm truncate block">
|
||||
{displayUrl}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isEmbeddedServer && <div className="flex-1" />}
|
||||
<div className="flex-1 mx-4 text-center">
|
||||
<span className="text-muted-foreground text-sm truncate block">
|
||||
{isEmbeddedServer ? t("serverConfig.localServer") : displayUrl}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-2 text-foreground hover:text-primary transition-colors"
|
||||
@@ -173,7 +170,7 @@ export function ElectronLoginForm({
|
||||
|
||||
{loading && !isAuthenticating && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center bg-canvas z-40"
|
||||
className="absolute inset-0 flex items-center justify-center bg-background z-40"
|
||||
style={{ marginTop: "60px" }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
type ServerConfig,
|
||||
} from "@/main-axios.ts";
|
||||
import { Server, Monitor, Loader2 } from "lucide-react";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
|
||||
interface ServerConfigProps {
|
||||
onServerConfigured: (serverUrl: string) => void;
|
||||
@@ -28,7 +27,6 @@ export function ElectronServerConfig({
|
||||
isFirstTime = false,
|
||||
}: ServerConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useTheme();
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [embeddedLoading, setEmbeddedLoading] = useState(false);
|
||||
@@ -37,16 +35,6 @@ export function ElectronServerConfig({
|
||||
null,
|
||||
);
|
||||
|
||||
const isDarkMode =
|
||||
theme === "dark" ||
|
||||
theme === "dracula" ||
|
||||
theme === "gentlemansChoice" ||
|
||||
theme === "midnightEspresso" ||
|
||||
theme === "catppuccinMocha" ||
|
||||
(theme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const lineColor = isDarkMode ? "#151517" : "#f9f9f9";
|
||||
|
||||
useEffect(() => {
|
||||
loadServerConfig();
|
||||
checkEmbeddedBackend();
|
||||
@@ -103,7 +91,8 @@ export function ElectronServerConfig({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const maxRetries = 10;
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
const maxRetries = 15;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
if (await probeBackend()) {
|
||||
setEmbeddedMode(true);
|
||||
@@ -174,62 +163,45 @@ export function ElectronServerConfig({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center"
|
||||
style={{
|
||||
background: "var(--bg-elevated)",
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 35px,
|
||||
${lineColor} 35px,
|
||||
${lineColor} 37px
|
||||
)`,
|
||||
}}
|
||||
>
|
||||
<div className="w-[420px] max-w-full p-8 flex flex-col backdrop-blur-sm bg-card/50 rounded-2xl shadow-xl border-2 border-edge overflow-y-auto thin-scrollbar my-2 animate-in fade-in zoom-in-95 duration-300">
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Server className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">{t("serverConfig.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{t("serverConfig.description")}
|
||||
</p>
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background p-6">
|
||||
<div className="flex flex-col gap-5 p-6 border border-border bg-card max-w-md w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Server className="size-4 text-accent-brand shrink-0" />
|
||||
<p className="font-bold">{t("serverConfig.title")}</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("serverConfig.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{embeddedAvailable !== false && (
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleUseEmbedded}
|
||||
disabled={embeddedLoading || loading}
|
||||
>
|
||||
{embeddedLoading ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>{t("serverConfig.embeddedConnecting")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
<span>{t("serverConfig.useEmbedded")}</span>
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 rounded border border-yellow-500/30">
|
||||
BETA
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t("serverConfig.embeddedDesc")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{embeddedAvailable !== false && (
|
||||
{embeddedAvailable !== false && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleUseEmbedded}
|
||||
disabled={embeddedLoading || loading}
|
||||
>
|
||||
{embeddedLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t("serverConfig.embeddedConnecting")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<Monitor className="size-4" />
|
||||
{t("serverConfig.useEmbedded")}
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border border-yellow-500/30">
|
||||
BETA
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground -mt-3">
|
||||
{t("serverConfig.embeddedDesc")}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -237,63 +209,61 @@ export function ElectronServerConfig({
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="server-url">{t("serverConfig.serverUrl")}</Label>
|
||||
<Input
|
||||
id="server-url"
|
||||
type="text"
|
||||
placeholder="https://your-server.com"
|
||||
value={serverUrl}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
disabled={loading || embeddedLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-url">{t("serverConfig.serverUrl")}</Label>
|
||||
<Input
|
||||
id="server-url"
|
||||
type="text"
|
||||
placeholder="https://your-server.com"
|
||||
value={serverUrl}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
className="w-full h-10"
|
||||
disabled={loading || embeddedLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{onCancel && !isFirstTime && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={onCancel}
|
||||
disabled={loading || embeddedLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{onCancel && !isFirstTime && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={onCancel && !isFirstTime ? "flex-1" : "w-full"}
|
||||
onClick={handleSaveConfig}
|
||||
disabled={loading || embeddedLoading || !serverUrl.trim()}
|
||||
className="flex-1"
|
||||
onClick={onCancel}
|
||||
disabled={loading || embeddedLoading}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<span>{t("serverConfig.saving")}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("serverConfig.saveConfig")
|
||||
)}
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{t("serverConfig.helpText")}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
className={`bg-accent-brand hover:bg-accent-brand/90 text-background font-bold ${onCancel && !isFirstTime ? "flex-1" : "w-full"}`}
|
||||
onClick={handleSaveConfig}
|
||||
disabled={loading || embeddedLoading || !serverUrl.trim()}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-background border-t-transparent rounded-full animate-spin" />
|
||||
{t("serverConfig.saving")}
|
||||
</span>
|
||||
) : (
|
||||
t("serverConfig.saveConfig")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t("serverConfig.helpText")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}` : "";
|
||||
}
|
||||
@@ -9,6 +9,8 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-none border border-input bg-transparent px-2.5 py-1 text-xs transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-xs file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 md:text-xs dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
type === "number" &&
|
||||
"[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface KbdProps extends React.HTMLAttributes<HTMLElement> {}
|
||||
export type KbdProps = React.HTMLAttributes<HTMLElement>;
|
||||
|
||||
const Kbd = React.forwardRef<HTMLElement, KbdProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
|
||||
@@ -13,7 +13,7 @@ export function SectionCard({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col border border-border bg-card">
|
||||
<div className="flex flex-col border border-border bg-card overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground flex-1">
|
||||
@@ -59,17 +59,20 @@ export function SettingRow({
|
||||
|
||||
export function FakeSwitch({
|
||||
defaultChecked = false,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
defaultChecked?: boolean;
|
||||
checked?: boolean;
|
||||
onChange?: (v: boolean) => void;
|
||||
}) {
|
||||
const [on, setOn] = useState(defaultChecked);
|
||||
const [internalOn, setInternalOn] = useState(defaultChecked);
|
||||
const on = checked !== undefined ? checked : internalOn;
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = !on;
|
||||
setOn(next);
|
||||
if (checked === undefined) setInternalOn(next);
|
||||
onChange?.(next);
|
||||
}}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${on ? "bg-accent-brand border-accent-brand" : "bg-muted border-border"}`}
|
||||
|
||||
@@ -37,21 +37,23 @@ function Slider({
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className={cn(
|
||||
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
|
||||
"bg-input relative grow overflow-hidden rounded-none data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className={cn(
|
||||
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
|
||||
"absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
|
||||
)}
|
||||
style={{ backgroundColor: "var(--accent-brand)" }}
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-elevated shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
className="block size-3.5 shrink-0 rounded-none shadow-sm transition-[color,box-shadow] hover:ring-4 hover:ring-[#f59145]/40 focus-visible:ring-4 focus-visible:ring-[#f59145]/40 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
style={{ backgroundColor: "var(--accent-brand)" }}
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
|
||||
@@ -129,6 +129,31 @@ export function Dashboard({
|
||||
[mainWidthPct, layout, updateLayout],
|
||||
);
|
||||
|
||||
const handleCardHeightMouseDown = useCallback(
|
||||
(e: React.MouseEvent, cardId: string, currentHeight: number) => {
|
||||
e.preventDefault();
|
||||
const startY = e.clientY;
|
||||
const startH = currentHeight;
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const newH = Math.max(180, startH + (ev.clientY - startY));
|
||||
if (!layout) return;
|
||||
updateLayout({
|
||||
...layout,
|
||||
cards: layout.cards.map((c) =>
|
||||
c.id === cardId ? { ...c, height: Math.round(newH) } : c,
|
||||
),
|
||||
});
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[layout, updateLayout],
|
||||
);
|
||||
|
||||
let sidebarState: "expanded" | "collapsed" = "expanded";
|
||||
try {
|
||||
const sidebar = useSidebar();
|
||||
@@ -385,7 +410,7 @@ export function Dashboard({
|
||||
name: string;
|
||||
cpu: number | null;
|
||||
ram: number | null;
|
||||
} => server !== null && server.cpu !== null && server.ram !== null,
|
||||
} => server !== null,
|
||||
);
|
||||
setServerStats(validServerStats);
|
||||
setServerStatsLoading(false);
|
||||
@@ -788,24 +813,40 @@ export function Dashboard({
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderCardWithHandle = (
|
||||
card: (typeof enabledCards)[0],
|
||||
) => {
|
||||
const currentHeight = card.height ?? 280;
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
className="flex flex-col"
|
||||
style={
|
||||
card.height
|
||||
? { height: card.height, flexShrink: 0 }
|
||||
: { flex: 1, minHeight: 280 }
|
||||
}
|
||||
>
|
||||
<div className="flex-1 min-h-0">{renderCard(card)}</div>
|
||||
<div
|
||||
className="flex-shrink-0 h-2 my-0.5 flex items-center justify-center cursor-row-resize group"
|
||||
onMouseDown={(e) =>
|
||||
handleCardHeightMouseDown(e, card.id, currentHeight)
|
||||
}
|
||||
>
|
||||
<div className="w-16 h-0.5 rounded-full bg-border group-hover:bg-primary/50 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex flex-col gap-4 overflow-auto min-h-0"
|
||||
className="flex flex-col gap-2 overflow-auto min-h-0"
|
||||
style={{ width: `${mainWidthPct}%`, minWidth: 0 }}
|
||||
>
|
||||
{mainCards.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
style={
|
||||
card.height
|
||||
? { height: card.height, flexShrink: 0 }
|
||||
: { flex: 1, minHeight: 280 }
|
||||
}
|
||||
>
|
||||
{renderCard(card)}
|
||||
</div>
|
||||
))}
|
||||
{mainCards.map((card) => renderCardWithHandle(card))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -816,21 +857,10 @@ export function Dashboard({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-col gap-4 overflow-auto min-h-0"
|
||||
className="flex flex-col gap-2 overflow-auto min-h-0"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
{sideCards.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
style={
|
||||
card.height
|
||||
? { height: card.height, flexShrink: 0 }
|
||||
: { flex: 1, minHeight: 280 }
|
||||
}
|
||||
>
|
||||
{renderCard(card)}
|
||||
</div>
|
||||
))}
|
||||
{sideCards.map((card) => renderCardWithHandle(card))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
+511
-375
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,21 @@ interface RecentActivityCardProps {
|
||||
onActivityClick: (item: RecentActivityItem) => void;
|
||||
}
|
||||
|
||||
function formatRelativeTime(
|
||||
timestamp: string,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
const diffMs = Date.now() - new Date(timestamp).getTime();
|
||||
if (diffMs < 0) return t("dashboard.justNow");
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
if (diffSec < 60) return t("dashboard.justNow");
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) return `${diffMin}m`;
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
if (diffHour < 24) return `${diffHour}h`;
|
||||
return `${Math.floor(diffHour / 24)}d`;
|
||||
}
|
||||
|
||||
export function RecentActivityCard({
|
||||
activities,
|
||||
loading,
|
||||
@@ -95,7 +110,14 @@ export function RecentActivityCard({
|
||||
) : (
|
||||
<Terminal size={20} className="shrink-0" />
|
||||
)}
|
||||
<p className="truncate ml-2 font-semibold">{item.hostName}</p>
|
||||
<div className="flex flex-col items-start min-w-0 ml-2">
|
||||
<p className="truncate font-semibold leading-none">
|
||||
{item.hostName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 leading-none">
|
||||
{formatRelativeTime(item.timestamp, t)}
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -56,22 +56,28 @@ export function ServerOverviewCard({
|
||||
<p className="leading-none text-muted-foreground">
|
||||
{versionText}
|
||||
</p>
|
||||
{!updateCheckDisabled && (
|
||||
{versionStatus === "beta" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-2 text-sm border-1 border-edge text-blue-400"
|
||||
>
|
||||
{t("dashboard.beta")}
|
||||
</Button>
|
||||
) : !updateCheckDisabled ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`ml-2 text-sm border-1 border-edge ${versionStatus === "up_to_date" ? "text-green-400" : versionStatus === "beta" ? "text-blue-400" : "text-yellow-400"}`}
|
||||
className={`ml-2 text-sm border-1 border-edge ${versionStatus === "up_to_date" ? "text-green-400" : "text-yellow-400"}`}
|
||||
>
|
||||
{versionStatus === "up_to_date"
|
||||
? t("dashboard.upToDate")
|
||||
: versionStatus === "beta"
|
||||
? t("dashboard.beta")
|
||||
: t("dashboard.updateAvailable")}
|
||||
: t("dashboard.updateAvailable")}
|
||||
</Button>
|
||||
<UpdateLog loggedIn={loggedIn} />
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ export function ServerStatsCard({
|
||||
}: ServerStatsCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const visibleStats = serverStats.filter(
|
||||
(s) => s.cpu !== null || s.ram !== null,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20 !bg-elevated">
|
||||
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
|
||||
@@ -38,12 +42,12 @@ export function ServerStatsCard({
|
||||
<Loader2 className="animate-spin mr-2" size={16} />
|
||||
<span>{t("dashboard.loadingServerStats")}</span>
|
||||
</div>
|
||||
) : serverStats.length === 0 ? (
|
||||
) : visibleStats.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("dashboard.noServerData")}
|
||||
</p>
|
||||
) : (
|
||||
serverStats.map((server) => (
|
||||
visibleStats.map((server) => (
|
||||
<Button
|
||||
key={server.id}
|
||||
variant="outline"
|
||||
@@ -56,18 +60,16 @@ export function ServerStatsCard({
|
||||
<p className="truncate ml-2 font-semibold">{server.name}</p>
|
||||
</div>
|
||||
<div className="flex flex-row justify-start gap-4 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("dashboard.cpu")}:{" "}
|
||||
{server.cpu !== null
|
||||
? `${server.cpu}%`
|
||||
: t("dashboard.notAvailable")}
|
||||
</span>
|
||||
<span>
|
||||
{t("dashboard.ram")}:{" "}
|
||||
{server.ram !== null
|
||||
? `${server.ram}%`
|
||||
: t("dashboard.notAvailable")}
|
||||
</span>
|
||||
{server.cpu !== null && (
|
||||
<span>
|
||||
{t("dashboard.cpu")}: {server.cpu.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
{server.ram !== null && (
|
||||
<span>
|
||||
{t("dashboard.ram")}: {server.ram.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
type DashboardLayout,
|
||||
} from "@/main-axios";
|
||||
|
||||
const LS_KEY = "dashboardLayout";
|
||||
|
||||
const DEFAULT_LAYOUT: DashboardLayout = {
|
||||
cards: [
|
||||
{ id: "server_overview", enabled: true, order: 1, panel: "main" },
|
||||
@@ -16,6 +18,42 @@ const DEFAULT_LAYOUT: DashboardLayout = {
|
||||
mainWidthPct: 68,
|
||||
};
|
||||
|
||||
function migrateLayout(preferences: DashboardLayout): DashboardLayout {
|
||||
const needsMigration = preferences.cards.some((c) => !c.panel);
|
||||
if (!needsMigration) return preferences;
|
||||
const defaultCardMap = new Map(DEFAULT_LAYOUT.cards.map((c) => [c.id, c]));
|
||||
return {
|
||||
...preferences,
|
||||
mainWidthPct: preferences.mainWidthPct ?? DEFAULT_LAYOUT.mainWidthPct,
|
||||
cards: preferences.cards.map((c) => ({
|
||||
...c,
|
||||
panel: c.panel ?? defaultCardMap.get(c.id)?.panel ?? "main",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function readFromLocalStorage(): DashboardLayout | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed?.cards && Array.isArray(parsed.cards)) {
|
||||
return migrateLayout(parsed);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeToLocalStorage(layout: DashboardLayout) {
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(layout));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function useDashboardPreferences(enabled: boolean = true) {
|
||||
const [layout, setLayout] = useState<DashboardLayout | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -28,34 +66,29 @@ export function useDashboardPreferences(enabled: boolean = true) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show cached layout immediately so the UI doesn't wait for the network
|
||||
const cached = readFromLocalStorage();
|
||||
if (cached) {
|
||||
setLayout(cached);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const fetchPreferences = async () => {
|
||||
try {
|
||||
const preferences = await getDashboardPreferences();
|
||||
if (preferences?.cards && Array.isArray(preferences.cards)) {
|
||||
// Migrate old layouts that don't have panel assignments
|
||||
const needsMigration = preferences.cards.some((c) => !c.panel);
|
||||
if (needsMigration) {
|
||||
const defaultCardMap = new Map(
|
||||
DEFAULT_LAYOUT.cards.map((c) => [c.id, c]),
|
||||
);
|
||||
const migrated: DashboardLayout = {
|
||||
...preferences,
|
||||
mainWidthPct:
|
||||
preferences.mainWidthPct ?? DEFAULT_LAYOUT.mainWidthPct,
|
||||
cards: preferences.cards.map((c) => ({
|
||||
...c,
|
||||
panel: c.panel ?? defaultCardMap.get(c.id)?.panel ?? "main",
|
||||
})),
|
||||
};
|
||||
setLayout(migrated);
|
||||
} else {
|
||||
setLayout(preferences);
|
||||
}
|
||||
const migrated = migrateLayout(preferences);
|
||||
setLayout(migrated);
|
||||
writeToLocalStorage(migrated);
|
||||
} else {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
if (!cached) {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
if (!cached) {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -67,6 +100,7 @@ export function useDashboardPreferences(enabled: boolean = true) {
|
||||
const updateLayout = useCallback(
|
||||
(newLayout: DashboardLayout) => {
|
||||
setLayout(newLayout);
|
||||
writeToLocalStorage(newLayout);
|
||||
|
||||
if (saveTimeout) {
|
||||
clearTimeout(saveTimeout);
|
||||
@@ -87,6 +121,7 @@ export function useDashboardPreferences(enabled: boolean = true) {
|
||||
|
||||
const resetLayout = useCallback(async () => {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
writeToLocalStorage(DEFAULT_LAYOUT);
|
||||
try {
|
||||
await saveDashboardPreferences(DEFAULT_LAYOUT);
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { SSHHost } from "@/types";
|
||||
import { Dashboard } from "@/dashboard/Dashboard.tsx";
|
||||
import { Toaster } from "@/components/sonner.tsx";
|
||||
import { dbHealthMonitor } from "@/lib/db-health-monitor.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
interface FullScreenAppWrapperProps {
|
||||
hostId?: string;
|
||||
@@ -17,6 +19,7 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
|
||||
hostId,
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [hostConfig, setHostConfig] = useState<SSHHost | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
@@ -84,13 +87,16 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div
|
||||
className="w-full h-screen overflow-hidden flex items-center justify-center"
|
||||
style={{ backgroundColor: "#18181b" }}
|
||||
className="w-full h-screen overflow-hidden flex flex-col items-center justify-center gap-4"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
<RefreshCw
|
||||
className="size-8 animate-spin"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<p className="text-sm" style={{ color: "var(--foreground-secondary)" }}>
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -102,7 +108,7 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
|
||||
<CommandHistoryProvider>
|
||||
<div
|
||||
className="w-full h-screen overflow-hidden flex items-center justify-center"
|
||||
style={{ backgroundColor: "#18181b" }}
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<Dashboard
|
||||
isAuthenticated={false}
|
||||
@@ -131,7 +137,7 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
|
||||
<CommandHistoryProvider>
|
||||
<div
|
||||
className="w-full h-screen overflow-hidden"
|
||||
style={{ backgroundColor: "#18181b" }}
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
{children(hostConfig, loading)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DockerManager } from "@/features/docker/DockerManager.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
|
||||
@@ -7,6 +8,7 @@ interface DockerAppProps {
|
||||
}
|
||||
|
||||
const DockerApp: React.FC<DockerAppProps> = ({ hostId }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
@@ -15,7 +17,9 @@ const DockerApp: React.FC<DockerAppProps> = ({ hostId }) => {
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading host...</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t("hosts.loadingHost")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -25,7 +29,7 @@ const DockerApp: React.FC<DockerAppProps> = ({ hostId }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">Host not found</p>
|
||||
<p className="text-red-500 mb-4">{t("hosts.hostNotFound")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Alert, AlertDescription } from "@/components/alert.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Card } from "@/components/card.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { AlertCircle, Box, RefreshCw, Search, Settings } from "lucide-react";
|
||||
import { AlertCircle, Box, RefreshCw, Search } from "lucide-react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SSHHost, DockerContainer, DockerValidation } from "@/types";
|
||||
@@ -94,6 +94,7 @@ function DockerManagerInner({
|
||||
const [hasConnectionError, setHasConnectionError] = React.useState(false);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||
const [retryCount, setRetryCount] = React.useState(0);
|
||||
|
||||
const activityLoggedRef = React.useRef(false);
|
||||
const activityLoggingRef = React.useRef(false);
|
||||
@@ -278,7 +279,7 @@ function DockerManagerInner({
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [currentHostConfig?.id, currentHostConfig?.enableDocker]);
|
||||
}, [currentHostConfig?.id, currentHostConfig?.enableDocker, retryCount]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!sessionId || !isVisible) return;
|
||||
@@ -539,6 +540,15 @@ function DockerManagerInner({
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
initializingRef.current = false;
|
||||
setSessionId(null);
|
||||
setHasConnectionError(false);
|
||||
setDockerValidation(null);
|
||||
clearLogs();
|
||||
setRetryCount((c) => c + 1);
|
||||
};
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = 8;
|
||||
const bottomMarginPx = 8;
|
||||
@@ -610,21 +620,32 @@ function DockerManagerInner({
|
||||
}
|
||||
|
||||
if (dockerValidation && !dockerValidation.available) {
|
||||
const isError =
|
||||
hasConnectionError || (!!dockerValidation && !dockerValidation.available);
|
||||
return (
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
{!isConnectionLogExpanded && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-10">
|
||||
<Box className="size-12 opacity-20" />
|
||||
<p className="text-sm text-muted-foreground opacity-60">
|
||||
{t("docker.connectionFailed")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={handleRetry}
|
||||
className="gap-2 font-semibold"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
{t("terminal.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ConnectionLog
|
||||
isConnecting={isConnecting}
|
||||
isConnected={!!sessionId && !!dockerValidation?.available}
|
||||
hasConnectionError={
|
||||
hasConnectionError ||
|
||||
(!!dockerValidation && !dockerValidation.available)
|
||||
}
|
||||
position={
|
||||
hasConnectionError ||
|
||||
(!!dockerValidation && !dockerValidation.available)
|
||||
? "top"
|
||||
: "bottom"
|
||||
}
|
||||
hasConnectionError={isError}
|
||||
position={isError ? "top" : "bottom"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -709,9 +730,6 @@ function DockerManagerInner({
|
||||
className={`size-4 text-accent-brand ${isLoadingContainers ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Settings className="size-4 text-accent-brand" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -773,6 +791,23 @@ function DockerManagerInner({
|
||||
visible={isConnecting && !isConnectionLogExpanded}
|
||||
message={t("docker.connecting")}
|
||||
/>
|
||||
{hasConnectionError && !isConnectionLogExpanded && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-10">
|
||||
<Box className="size-12 opacity-20" />
|
||||
<p className="text-sm text-muted-foreground opacity-60">
|
||||
{t("docker.connectionFailed")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={handleRetry}
|
||||
className="gap-2 font-semibold"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
{t("terminal.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ConnectionLog
|
||||
isConnecting={isConnecting}
|
||||
isConnected={!!sessionId && !!dockerValidation?.available}
|
||||
|
||||
@@ -20,6 +20,12 @@ import type { SSHHost } from "@/types";
|
||||
import { isElectron } from "@/main-axios.ts";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TERMINAL_THEMES,
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
TERMINAL_FONTS,
|
||||
} from "@/lib/terminal-themes";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
|
||||
interface ConsoleTerminalProps {
|
||||
containerId: string;
|
||||
@@ -35,7 +41,35 @@ export function ConsoleTerminal({
|
||||
hostConfig,
|
||||
}: ConsoleTerminalProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { theme: appTheme } = useTheme();
|
||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||
|
||||
const terminalConfig = React.useMemo(
|
||||
() => ({ ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig }),
|
||||
[hostConfig.terminalConfig],
|
||||
);
|
||||
|
||||
const isDarkMode =
|
||||
appTheme === "dark" ||
|
||||
appTheme === "dracula" ||
|
||||
appTheme === "gentlemansChoice" ||
|
||||
appTheme === "midnightEspresso" ||
|
||||
appTheme === "catppuccinMocha" ||
|
||||
(appTheme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
|
||||
const themeColors = React.useMemo(() => {
|
||||
const activeTheme = terminalConfig.theme;
|
||||
if (activeTheme === "termix") {
|
||||
return isDarkMode
|
||||
? TERMINAL_THEMES.termixDark.colors
|
||||
: TERMINAL_THEMES.termixLight.colors;
|
||||
}
|
||||
return (
|
||||
TERMINAL_THEMES[activeTheme]?.colors ?? TERMINAL_THEMES.termixDark.colors
|
||||
);
|
||||
}, [terminalConfig.theme, isDarkMode]);
|
||||
|
||||
const [isConnected, setIsConnected] = React.useState(false);
|
||||
const [isConnecting, setIsConnecting] = React.useState(false);
|
||||
const [selectedShell, setSelectedShell] = React.useState<string>("bash");
|
||||
@@ -57,9 +91,18 @@ export function ConsoleTerminal({
|
||||
terminal.loadAddon(clipboardAddon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
|
||||
terminal.options.cursorBlink = true;
|
||||
terminal.options.fontSize = 14;
|
||||
terminal.options.fontFamily = "monospace";
|
||||
const fontConfig = TERMINAL_FONTS.find(
|
||||
(f) => f.value === terminalConfig.fontFamily,
|
||||
);
|
||||
const fontFamily = fontConfig?.fallback ?? TERMINAL_FONTS[0].fallback;
|
||||
|
||||
terminal.options.cursorBlink = terminalConfig.cursorBlink;
|
||||
terminal.options.cursorStyle = terminalConfig.cursorStyle;
|
||||
terminal.options.fontSize = terminalConfig.fontSize;
|
||||
terminal.options.fontFamily = fontFamily;
|
||||
terminal.options.scrollback = terminalConfig.scrollback;
|
||||
terminal.options.letterSpacing = terminalConfig.letterSpacing;
|
||||
terminal.options.lineHeight = terminalConfig.lineHeight;
|
||||
|
||||
const readTextFromClipboard = async (): Promise<string> => {
|
||||
if (window.electronClipboard) {
|
||||
@@ -157,16 +200,29 @@ export function ConsoleTerminal({
|
||||
return true;
|
||||
});
|
||||
|
||||
const backgroundColor = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--bg-elevated")
|
||||
.trim();
|
||||
const foregroundColor = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--foreground")
|
||||
.trim();
|
||||
|
||||
terminal.options.theme = {
|
||||
background: backgroundColor || "var(--bg-elevated)",
|
||||
foreground: foregroundColor || "var(--foreground)",
|
||||
background: themeColors.background,
|
||||
foreground: themeColors.foreground,
|
||||
cursor: themeColors.cursor,
|
||||
cursorAccent: themeColors.cursorAccent,
|
||||
selectionBackground: themeColors.selectionBackground,
|
||||
selectionForeground: themeColors.selectionForeground,
|
||||
black: themeColors.black,
|
||||
red: themeColors.red,
|
||||
green: themeColors.green,
|
||||
yellow: themeColors.yellow,
|
||||
blue: themeColors.blue,
|
||||
magenta: themeColors.magenta,
|
||||
cyan: themeColors.cyan,
|
||||
white: themeColors.white,
|
||||
brightBlack: themeColors.brightBlack,
|
||||
brightRed: themeColors.brightRed,
|
||||
brightGreen: themeColors.brightGreen,
|
||||
brightYellow: themeColors.brightYellow,
|
||||
brightBlue: themeColors.brightBlue,
|
||||
brightMagenta: themeColors.brightMagenta,
|
||||
brightCyan: themeColors.brightCyan,
|
||||
brightWhite: themeColors.brightWhite,
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -207,7 +263,7 @@ export function ConsoleTerminal({
|
||||
|
||||
terminal.dispose();
|
||||
};
|
||||
}, [terminal, t]);
|
||||
}, [terminal, t, terminalConfig, themeColors]);
|
||||
|
||||
const disconnect = React.useCallback(() => {
|
||||
if (wsRef.current) {
|
||||
@@ -498,7 +554,10 @@ export function ConsoleTerminal({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="flex-1 overflow-hidden pt-1 pb-0">
|
||||
<Card
|
||||
className="flex-1 overflow-hidden pt-1 pb-0"
|
||||
style={{ background: themeColors.background }}
|
||||
>
|
||||
<CardContent className="p-0 h-full relative">
|
||||
<div
|
||||
ref={xtermRef}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Activity,
|
||||
ArrowLeft,
|
||||
Box,
|
||||
Info,
|
||||
List,
|
||||
Settings,
|
||||
Terminal,
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
Search,
|
||||
Grid3X3,
|
||||
List,
|
||||
ArrowUpDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ArrowUp,
|
||||
@@ -48,8 +47,6 @@ import {
|
||||
Copy,
|
||||
Layout,
|
||||
} from "lucide-react";
|
||||
import { Card } from "@/components/card.tsx";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -73,7 +70,6 @@ import {
|
||||
listSSHFiles,
|
||||
resolveSSHPath,
|
||||
uploadSSHFile,
|
||||
downloadSSHFile,
|
||||
createSSHFile,
|
||||
createSSHFolder,
|
||||
deleteSSHItem,
|
||||
@@ -260,6 +256,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
|
||||
const { dragHandlers } = useDragAndDrop({
|
||||
onFilesDropped: handleFilesDropped,
|
||||
onItemsDropped: handleItemsDropped,
|
||||
onError: (error) => toast.error(error),
|
||||
maxFileSize: 5120,
|
||||
});
|
||||
@@ -412,6 +409,17 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
async function initializeSSHConnection() {
|
||||
if (!currentHost || isConnectingRef.current) return;
|
||||
|
||||
if (currentHost.enableSsh === false) {
|
||||
setHasConnectionError(true);
|
||||
addLog({
|
||||
type: "error",
|
||||
message: t("fileManager.sshRequiredForFileManager"),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
isConnectingRef.current = true;
|
||||
|
||||
try {
|
||||
@@ -532,7 +540,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}
|
||||
|
||||
handleCloseWithError(
|
||||
t("fileManager.failedToConnect") + ": " + (error.message || error),
|
||||
t("fileManager.failedToConnect") +
|
||||
": " +
|
||||
(error instanceof Error ? error.message : String(error)),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -547,10 +557,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isLoading && currentLoadingPathRef.current !== path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let resolvedPath = path;
|
||||
if (path.includes("$") || path.startsWith("~")) {
|
||||
resolvedPath = await resolveSSHPath(sshSessionId, path);
|
||||
@@ -563,8 +569,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
currentLoadingPathRef.current = resolvedPath;
|
||||
setIsLoading(true);
|
||||
|
||||
setCreateIntent(null);
|
||||
|
||||
try {
|
||||
const response = await listSSHFiles(sshSessionId, resolvedPath);
|
||||
|
||||
@@ -661,9 +665,17 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
|
||||
return false;
|
||||
} else if (initialLoadDoneRef.current) {
|
||||
toast.error(
|
||||
t("fileManager.failedToLoadDirectory") + ": " + errorMessage,
|
||||
);
|
||||
const isPermissionDenied =
|
||||
httpStatus === 403 ||
|
||||
errorMessage?.toLowerCase().includes("permission denied") ||
|
||||
errorMessage?.toLowerCase().includes("eacces");
|
||||
if (isPermissionDenied) {
|
||||
toast.error(t("fileManager.permissionDenied"));
|
||||
} else {
|
||||
toast.error(
|
||||
t("fileManager.failedToLoadDirectory") + ": " + errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -695,6 +707,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
|
||||
const navigateTo = useCallback(
|
||||
(path: string) => {
|
||||
if (sshSessionId) setIsLoading(true);
|
||||
setCurrentPath(path);
|
||||
setNavHistory((prev) => {
|
||||
const next = [...prev.slice(0, navIndex + 1), path];
|
||||
@@ -702,24 +715,26 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[navIndex],
|
||||
[navIndex, sshSessionId],
|
||||
);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (navIndex > 0) {
|
||||
if (sshSessionId) setIsLoading(true);
|
||||
const newIndex = navIndex - 1;
|
||||
setNavIndex(newIndex);
|
||||
setCurrentPath(navHistory[newIndex]);
|
||||
}
|
||||
}, [navIndex, navHistory]);
|
||||
}, [navIndex, navHistory, sshSessionId]);
|
||||
|
||||
const goForward = useCallback(() => {
|
||||
if (navIndex < navHistory.length - 1) {
|
||||
if (sshSessionId) setIsLoading(true);
|
||||
const newIndex = navIndex + 1;
|
||||
setNavIndex(newIndex);
|
||||
setCurrentPath(navHistory[newIndex]);
|
||||
}
|
||||
}, [navIndex, navHistory]);
|
||||
}, [navIndex, navHistory, sshSessionId]);
|
||||
|
||||
const goUp = useCallback(() => {
|
||||
if (currentPath === "/") return;
|
||||
@@ -783,6 +798,125 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [currentPath]);
|
||||
|
||||
async function handleItemsDropped(items: DataTransferItemList) {
|
||||
if (!sshSessionId) {
|
||||
toast.error(t("fileManager.noSSHConnection"));
|
||||
return;
|
||||
}
|
||||
|
||||
const entries: FileSystemEntry[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const entry = items[i].webkitGetAsEntry?.();
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
|
||||
const files: { file: File; relativePath: string }[] = [];
|
||||
|
||||
async function readEntry(
|
||||
entry: FileSystemEntry,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise<File>((resolve, reject) =>
|
||||
(entry as FileSystemFileEntry).file(resolve, reject),
|
||||
);
|
||||
files.push({ file, relativePath: path });
|
||||
} else if (entry.isDirectory) {
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||
const dirEntries = await new Promise<FileSystemEntry[]>(
|
||||
(resolve, reject) => reader.readEntries(resolve, reject),
|
||||
);
|
||||
for (const child of dirEntries) {
|
||||
await readEntry(child, `${path}/${child.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
await readEntry(entry, entry.name);
|
||||
}
|
||||
|
||||
if (files.length === 0) return;
|
||||
|
||||
const progressToast = toast.loading(
|
||||
`Uploading ${files.length} file(s)...`,
|
||||
{ duration: Infinity },
|
||||
);
|
||||
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
|
||||
const dirs = new Set<string>();
|
||||
for (const { relativePath } of files) {
|
||||
const parts = relativePath.split("/");
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
dirs.add(parts.slice(0, i).join("/"));
|
||||
}
|
||||
}
|
||||
|
||||
const sortedDirs = Array.from(dirs).sort();
|
||||
for (const dir of sortedDirs) {
|
||||
const parentPath = currentPath.endsWith("/")
|
||||
? currentPath + dir.split("/").slice(0, -1).join("/")
|
||||
: currentPath + "/" + dir.split("/").slice(0, -1).join("/");
|
||||
const folderName = dir.split("/").pop()!;
|
||||
const targetPath = parentPath.endsWith("/")
|
||||
? parentPath
|
||||
: parentPath + "/";
|
||||
try {
|
||||
await createSSHFolder(
|
||||
sshSessionId,
|
||||
targetPath,
|
||||
folderName,
|
||||
currentHost?.id,
|
||||
);
|
||||
} catch {
|
||||
// directory may already exist
|
||||
}
|
||||
}
|
||||
|
||||
for (const { file, relativePath } of files) {
|
||||
const dirPart = relativePath.includes("/")
|
||||
? relativePath.substring(0, relativePath.lastIndexOf("/"))
|
||||
: "";
|
||||
const uploadPath = dirPart
|
||||
? (currentPath.endsWith("/") ? currentPath : currentPath + "/") +
|
||||
dirPart +
|
||||
"/"
|
||||
: currentPath;
|
||||
|
||||
const fileContent = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result.split(",")[1] || "");
|
||||
} else {
|
||||
reject(new Error("Failed to read file"));
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
await uploadSSHFile(
|
||||
sshSessionId,
|
||||
uploadPath,
|
||||
file.name,
|
||||
fileContent,
|
||||
currentHost?.id,
|
||||
);
|
||||
}
|
||||
|
||||
toast.dismiss(progressToast);
|
||||
toast.success(`Uploaded ${files.length} file(s) successfully`);
|
||||
handleRefreshDirectory();
|
||||
} catch (error) {
|
||||
toast.dismiss(progressToast);
|
||||
toast.error(t("fileManager.failedToUploadFile"));
|
||||
console.error("Folder upload failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFilesDropped(fileList: FileList) {
|
||||
if (!sshSessionId) {
|
||||
toast.error(t("fileManager.noSSHConnection"));
|
||||
@@ -840,10 +974,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
handleRefreshDirectory();
|
||||
} catch (error: unknown) {
|
||||
toast.dismiss(progressToast);
|
||||
|
||||
const uploadErr = error instanceof Error ? error : null;
|
||||
if (
|
||||
error.message?.includes("connection") ||
|
||||
error.message?.includes("established")
|
||||
uploadErr?.message?.includes("connection") ||
|
||||
uploadErr?.message?.includes("established")
|
||||
) {
|
||||
toast.error(
|
||||
t("fileManager.sshConnectionFailed", {
|
||||
@@ -865,38 +999,17 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
|
||||
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||
const { downloadSSHFileStream } = await import("@/main-axios.ts");
|
||||
await downloadSSHFileStream(sshSessionId, file.path);
|
||||
|
||||
if (response?.content) {
|
||||
const byteCharacters = atob(response.content);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], {
|
||||
type: response.mimeType || "application/octet-stream",
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = response.fileName || file.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(
|
||||
t("fileManager.fileDownloadedSuccessfully", { name: file.name }),
|
||||
);
|
||||
} else {
|
||||
toast.error(t("fileManager.failedToDownloadFile"));
|
||||
}
|
||||
toast.success(
|
||||
t("fileManager.fileDownloadedSuccessfully", { name: file.name }),
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const err = error instanceof Error ? error : null;
|
||||
if (
|
||||
error.message?.includes("connection") ||
|
||||
error.message?.includes("established")
|
||||
err?.message?.includes("connection") ||
|
||||
err?.message?.includes("established")
|
||||
) {
|
||||
toast.error(
|
||||
t("fileManager.sshConnectionFailed", {
|
||||
@@ -980,7 +1093,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
clearSelection();
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { needsSudo?: boolean; error?: string } };
|
||||
response?: {
|
||||
data?: { needsSudo?: boolean; error?: string };
|
||||
status?: number;
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
if (axiosError.response?.data?.needsSudo) {
|
||||
@@ -989,11 +1105,22 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
axiosError.response?.status === 403 ||
|
||||
axiosError.response?.data?.error
|
||||
?.toLowerCase()
|
||||
.includes("permission denied")
|
||||
) {
|
||||
toast.error(t("fileManager.permissionDenied"));
|
||||
} else if (
|
||||
axiosError.message?.includes("connection") ||
|
||||
axiosError.message?.includes("established")
|
||||
) {
|
||||
toast.error(
|
||||
`SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`,
|
||||
t("fileManager.sshConnectionFailed", {
|
||||
name: currentHost?.name,
|
||||
ip: currentHost?.ip,
|
||||
port: currentHost?.port,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.error(t("fileManager.failedToDeleteItems"));
|
||||
@@ -1061,14 +1188,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
t("fileManager.newFolderDefault"),
|
||||
"directory",
|
||||
);
|
||||
const newCreateIntent = {
|
||||
setCreateIntent({
|
||||
id: Date.now().toString(),
|
||||
type: "directory" as const,
|
||||
defaultName,
|
||||
currentName: defaultName,
|
||||
};
|
||||
|
||||
setCreateIntent(newCreateIntent);
|
||||
});
|
||||
}
|
||||
|
||||
function handleCreateNewFile() {
|
||||
@@ -1076,13 +1201,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
t("fileManager.newFileDefault"),
|
||||
"file",
|
||||
);
|
||||
const newCreateIntent = {
|
||||
setCreateIntent({
|
||||
id: Date.now().toString(),
|
||||
type: "file" as const,
|
||||
defaultName,
|
||||
currentName: defaultName,
|
||||
};
|
||||
setCreateIntent(newCreateIntent);
|
||||
});
|
||||
}
|
||||
|
||||
const handleSymlinkClick = async (file: FileItem) => {
|
||||
@@ -1166,6 +1290,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
|
||||
async function handleFileOpen(file: FileItem) {
|
||||
if (file.type === "directory") {
|
||||
if (sshSessionId) setIsLoading(true);
|
||||
setCurrentPath(file.path);
|
||||
} else if (file.type === "link") {
|
||||
await handleSymlinkClick(file);
|
||||
@@ -1308,16 +1433,28 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(`Failed to ${operation} file ${file.name}:`, error);
|
||||
toast.error(
|
||||
t("fileManager.operationFailed", {
|
||||
operation:
|
||||
operation === "copy"
|
||||
? t("fileManager.copy")
|
||||
: t("fileManager.move"),
|
||||
name: file.name,
|
||||
error: error.message,
|
||||
}),
|
||||
);
|
||||
const axiosError = error as {
|
||||
response?: { status?: number; data?: { error?: string } };
|
||||
};
|
||||
if (
|
||||
axiosError.response?.status === 403 ||
|
||||
axiosError.response?.data?.error
|
||||
?.toLowerCase()
|
||||
.includes("permission denied")
|
||||
) {
|
||||
toast.error(t("fileManager.permissionDenied"));
|
||||
} else {
|
||||
toast.error(
|
||||
t("fileManager.operationFailed", {
|
||||
operation:
|
||||
operation === "copy"
|
||||
? t("fileManager.copy")
|
||||
: t("fileManager.move"),
|
||||
name: file.name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1408,9 +1545,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
setClipboard(null);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
`${t("fileManager.pasteFailed")}: ${error.message || t("fileManager.unknownError")}`,
|
||||
);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`${t("fileManager.pasteFailed")}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1436,10 +1573,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
|
||||
handleRefreshDirectory();
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
toast.error(
|
||||
`${t("fileManager.extractFailed")}: ${err.message || t("fileManager.unknownError")}`,
|
||||
);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`${t("fileManager.extractFailed")}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1481,10 +1617,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
handleRefreshDirectory();
|
||||
clearSelection();
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
toast.error(
|
||||
`${t("fileManager.compressFailed")}: ${err.message || t("fileManager.unknownError")}`,
|
||||
);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`${t("fileManager.compressFailed")}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1524,7 +1659,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
toast.error(
|
||||
t("fileManager.deleteCopiedFileFailed", {
|
||||
name: copiedFile.targetName,
|
||||
error: error.message,
|
||||
error:
|
||||
error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1566,7 +1702,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
toast.error(
|
||||
t("fileManager.moveBackFileFailed", {
|
||||
name: movedFile.targetName,
|
||||
error: error.message,
|
||||
error:
|
||||
error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1599,9 +1736,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
|
||||
handleRefreshDirectory();
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
`${t("fileManager.undoOperationFailed")}: ${error.message || t("fileManager.unknownError")}`,
|
||||
);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`${t("fileManager.undoOperationFailed")}: ${errorMessage}`);
|
||||
console.error("Undo failed:", error);
|
||||
}
|
||||
}
|
||||
@@ -1696,8 +1833,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
setCreateIntent(null);
|
||||
handleRefreshDirectory();
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as {
|
||||
response?: { status?: number; data?: { error?: string } };
|
||||
};
|
||||
if (
|
||||
axiosError.response?.status === 403 ||
|
||||
axiosError.response?.data?.error
|
||||
?.toLowerCase()
|
||||
.includes("permission denied")
|
||||
) {
|
||||
toast.error(t("fileManager.permissionDenied"));
|
||||
} else {
|
||||
toast.error(t("fileManager.failedToCreateItem"));
|
||||
}
|
||||
console.error("Create failed:", error);
|
||||
toast.error(t("fileManager.failedToCreateItem"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1725,8 +1874,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
setEditingFile(null);
|
||||
handleRefreshDirectory();
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as {
|
||||
response?: { status?: number; data?: { error?: string } };
|
||||
};
|
||||
if (
|
||||
axiosError.response?.status === 403 ||
|
||||
axiosError.response?.data?.error
|
||||
?.toLowerCase()
|
||||
.includes("permission denied")
|
||||
) {
|
||||
toast.error(t("fileManager.permissionDenied"));
|
||||
} else {
|
||||
toast.error(t("fileManager.failedToRenameItem"));
|
||||
}
|
||||
console.error("Rename failed:", error);
|
||||
toast.error(t("fileManager.failedToRenameItem"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1910,7 +2071,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
setAuthDialogReason("auth_failed");
|
||||
setShowAuthDialog(true);
|
||||
toast.error(
|
||||
t("fileManager.failedToConnect") + ": " + (error.message || error),
|
||||
t("fileManager.failedToConnect") +
|
||||
": " +
|
||||
(error instanceof Error ? error.message : String(error)),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -1979,7 +2142,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
toast.error(
|
||||
t("fileManager.moveFileFailed", { name: file.name }) +
|
||||
": " +
|
||||
error.message,
|
||||
(error instanceof Error ? error.message : String(error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2022,7 +2185,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("Drag move operation failed:", error);
|
||||
toast.error(t("fileManager.moveOperationFailed") + ": " + error.message);
|
||||
toast.error(
|
||||
t("fileManager.moveOperationFailed") +
|
||||
": " +
|
||||
(error instanceof Error ? error.message : String(error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2096,7 +2263,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
toast.error(
|
||||
t("fileManager.dragFailed") +
|
||||
": " +
|
||||
(error.message || t("fileManager.unknownError")),
|
||||
(error instanceof Error ? error.message : String(error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2362,15 +2529,34 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if ((isLoading || isReconnecting) && !sshSessionId) {
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col bg-background relative">
|
||||
<div className="flex-1 overflow-hidden min-h-0 relative">
|
||||
<SimpleLoader
|
||||
visible={!isConnectionLogExpanded}
|
||||
message={t("fileManager.connecting")}
|
||||
/>
|
||||
</div>
|
||||
<ConnectionLog
|
||||
isConnecting={isLoading || isReconnecting}
|
||||
isConnected={false}
|
||||
hasConnectionError={hasConnectionError}
|
||||
position={hasConnectionError ? "top" : "bottom"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background relative">
|
||||
<div className="h-full flex flex-col bg-background relative overflow-hidden isolate">
|
||||
<div
|
||||
className="h-full w-full flex flex-col"
|
||||
className="h-full w-full flex flex-col min-h-0"
|
||||
style={{
|
||||
visibility: isConnectionLogExpanded ? "hidden" : "visible",
|
||||
}}
|
||||
>
|
||||
<Card className="flex flex-col shrink-0 mx-3 mt-3 rounded-none shadow-none border-border">
|
||||
<div className="flex flex-col shrink-0 mx-3 mt-3 border border-border bg-card">
|
||||
<div className="flex flex-row items-center justify-between px-3 py-2 gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
@@ -2413,11 +2599,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleRefreshDirectory}
|
||||
disabled={isLoading}
|
||||
className="size-8 rounded-none"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-4 ${isLoading ? "animate-spin" : ""}`}
|
||||
className={`size-4 ${isLoading && !!sshSessionId ? "animate-spin [animation-duration:0.5s]" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2538,16 +2723,21 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-44 rounded-none border-border bg-card"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={handleCreateNewFolder}
|
||||
onSelect={() => {
|
||||
setTimeout(() => handleCreateNewFolder(), 0);
|
||||
}}
|
||||
className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand"
|
||||
>
|
||||
<FolderPlus className="size-4 text-accent-brand" />
|
||||
{t("fileManager.newFolder")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={handleCreateNewFile}
|
||||
onSelect={() => {
|
||||
setTimeout(() => handleCreateNewFile(), 0);
|
||||
}}
|
||||
className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand"
|
||||
>
|
||||
<FilePlus className="size-4 text-muted-foreground" />
|
||||
@@ -2640,7 +2830,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 flex px-3 pb-3 pt-2 gap-3 min-h-0 relative"
|
||||
@@ -2664,7 +2854,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
: "hidden md:flex",
|
||||
)}
|
||||
>
|
||||
<Card className="flex-1 flex flex-col rounded-none shadow-none p-0 gap-0 overflow-hidden border-border">
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-h-0 border border-border bg-card">
|
||||
<FileManagerSidebar
|
||||
currentHost={currentHost}
|
||||
currentPath={currentPath}
|
||||
@@ -2675,10 +2865,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
refreshTrigger={sidebarRefreshTrigger}
|
||||
diskInfo={diskInfo ?? undefined}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="flex-1 relative overflow-hidden rounded-none shadow-none p-0 gap-0 min-h-0 flex flex-col border-border">
|
||||
<div className="flex-1 relative overflow-hidden min-h-0 flex flex-col border border-border bg-card">
|
||||
<div className="flex-1 relative min-h-0 h-full">
|
||||
<FileManagerGrid
|
||||
files={filteredFiles}
|
||||
@@ -2687,7 +2877,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
onFileOpen={handleFileOpen}
|
||||
onSelectionChange={setSelection}
|
||||
currentPath={currentPath}
|
||||
isLoading={isLoading}
|
||||
onPathChange={navigateTo}
|
||||
onRefresh={handleRefreshDirectory}
|
||||
onUpload={handleFilesDropped}
|
||||
@@ -2701,7 +2890,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
onDownload={(files) => files.forEach(handleDownloadFile)}
|
||||
onDownload={(files) =>
|
||||
files
|
||||
.filter((f) => f.type === "file")
|
||||
.forEach(handleDownloadFile)
|
||||
}
|
||||
onContextMenu={handleContextMenu}
|
||||
viewMode={viewMode}
|
||||
onRename={handleRenameConfirm}
|
||||
@@ -2733,7 +2926,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
onClose={() =>
|
||||
setContextMenu((prev) => ({ ...prev, isVisible: false }))
|
||||
}
|
||||
onDownload={(files) => files.forEach(handleDownloadFile)}
|
||||
onDownload={(files) =>
|
||||
files
|
||||
.filter((f) => f.type === "file")
|
||||
.forEach(handleDownloadFile)
|
||||
}
|
||||
onPreview={handleFileOpen}
|
||||
onRename={handleRenameFile}
|
||||
onCopy={handleCopyFiles}
|
||||
onCut={handleCutFiles}
|
||||
@@ -2767,7 +2965,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
onCopyPath={handleCopyPath}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2783,6 +2981,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
prompt={totpPrompt}
|
||||
onSubmit={handleTotpSubmit}
|
||||
onCancel={handleTotpCancel}
|
||||
backgroundColor="var(--bg-canvas)"
|
||||
/>
|
||||
|
||||
<WarpgateDialog
|
||||
@@ -2792,6 +2991,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
onContinue={handleWarpgateContinue}
|
||||
onCancel={handleWarpgateCancel}
|
||||
onOpenUrl={handleWarpgateOpenUrl}
|
||||
backgroundColor="var(--bg-canvas)"
|
||||
/>
|
||||
|
||||
{currentHost && (
|
||||
@@ -2806,6 +3006,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
username: currentHost.username,
|
||||
name: currentHost.name,
|
||||
}}
|
||||
backgroundColor="var(--bg-canvas)"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2826,10 +3027,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}}
|
||||
onSubmit={handleSudoPasswordSubmit}
|
||||
/>
|
||||
<SimpleLoader
|
||||
visible={(isReconnecting || isLoading) && !isConnectionLogExpanded}
|
||||
message={t("fileManager.connecting")}
|
||||
/>
|
||||
<ConnectionLog
|
||||
isConnecting={isReconnecting || isLoading}
|
||||
isConnected={!!sshSessionId}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileManager } from "@/features/file-manager/FileManager.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
|
||||
@@ -7,6 +8,7 @@ interface FileManagerAppProps {
|
||||
}
|
||||
|
||||
const FileManagerApp: React.FC<FileManagerAppProps> = ({ hostId }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
@@ -15,7 +17,9 @@ const FileManagerApp: React.FC<FileManagerAppProps> = ({ hostId }) => {
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading host...</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t("hosts.loadingHost")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -25,7 +29,7 @@ const FileManagerApp: React.FC<FileManagerAppProps> = ({ hostId }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">Host not found</p>
|
||||
<p className="text-red-500 mb-4">{t("hosts.hostNotFound")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import {
|
||||
Download,
|
||||
@@ -143,14 +143,6 @@ export function FileManagerContextMenu({
|
||||
|
||||
setIsMounted(true);
|
||||
|
||||
const adjustPosition = () => {
|
||||
const menuWidth = menuRef.current?.offsetWidth ?? 260;
|
||||
const menuHeight = menuRef.current?.offsetHeight ?? 400;
|
||||
setMenuPosition(getClampedMenuPosition(x, y, menuWidth, menuHeight));
|
||||
};
|
||||
|
||||
adjustPosition();
|
||||
|
||||
let cleanupFn: (() => void) | null = null;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
@@ -206,6 +198,21 @@ export function FileManagerContextMenu({
|
||||
};
|
||||
}, [isVisible, x, y, onClose]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isVisible || !menuRef.current) return;
|
||||
const menuWidth = menuRef.current.offsetWidth;
|
||||
const menuHeight = menuRef.current.offsetHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
let adjustedX = x;
|
||||
let adjustedY = y;
|
||||
if (x + menuWidth > viewportWidth)
|
||||
adjustedX = viewportWidth - menuWidth - 10;
|
||||
if (y + menuHeight > viewportHeight)
|
||||
adjustedY = Math.max(10, viewportHeight - menuHeight - 10);
|
||||
setMenuPosition({ x: adjustedX, y: adjustedY });
|
||||
}, [isVisible, x, y, files.length]);
|
||||
|
||||
const isFileContext = files.length > 0;
|
||||
const isSingleFile = files.length === 1;
|
||||
const isMultipleFiles = files.length > 1;
|
||||
@@ -528,9 +535,8 @@ export function FileManagerContextMenu({
|
||||
<div
|
||||
ref={menuRef}
|
||||
data-context-menu
|
||||
<<<<<<< HEAD:src/ui/desktop/apps/features/file-manager/FileManagerContextMenu.tsx
|
||||
className={cn(
|
||||
"fixed bg-canvas border border-edge rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-x-hidden overflow-y-auto",
|
||||
"fixed bg-card border border-border rounded-none shadow-md min-w-[220px] max-w-[300px] z-[99995] overflow-x-hidden overflow-y-auto py-1",
|
||||
)}
|
||||
style={{
|
||||
left: menuPosition.x,
|
||||
@@ -543,7 +549,7 @@ export function FileManagerContextMenu({
|
||||
return (
|
||||
<div
|
||||
key={`separator-${index}`}
|
||||
className="border-t border-border"
|
||||
className="my-1 border-t border-border"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -552,10 +558,10 @@ export function FileManagerContextMenu({
|
||||
<button
|
||||
key={index}
|
||||
className={cn(
|
||||
"w-full px-3 h-9 text-left text-[10px] font-bold uppercase tracking-widest flex items-center justify-between",
|
||||
"hover:bg-accent-brand/10 hover:text-accent-brand transition-colors cursor-pointer rounded-none",
|
||||
"w-full px-3 min-h-8 py-1.5 text-left text-xs font-semibold flex items-center justify-between gap-3 rounded-none transition-colors cursor-pointer",
|
||||
"hover:bg-accent-brand/10 hover:text-accent-brand",
|
||||
item.disabled &&
|
||||
"opacity-50 cursor-not-allowed hover:bg-transparent hover:text-current",
|
||||
"opacity-40 cursor-not-allowed hover:bg-transparent hover:text-current",
|
||||
item.danger &&
|
||||
"text-destructive hover:bg-destructive/10 hover:text-destructive",
|
||||
)}
|
||||
@@ -567,12 +573,14 @@ export function FileManagerContextMenu({
|
||||
}}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 flex-1 min-w-0">
|
||||
<div className="flex-shrink-0">{item.icon}</div>
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="flex-shrink-0 text-muted-foreground">
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="flex-1 leading-tight">{item.label}</span>
|
||||
</div>
|
||||
{item.shortcut && (
|
||||
<div className="ml-2 flex-shrink-0">
|
||||
<div className="ml-auto flex-shrink-0 opacity-50">
|
||||
{renderShortcut(item.shortcut)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -13,19 +13,14 @@ import {
|
||||
Settings,
|
||||
Download,
|
||||
Upload,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
FileSymlink,
|
||||
Move,
|
||||
GitCompare,
|
||||
Edit,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FileItem } from "@/types/index";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
|
||||
interface CreateIntent {
|
||||
id: string;
|
||||
@@ -70,7 +65,6 @@ interface FileManagerGridProps {
|
||||
onFileOpen: (file: FileItem) => void;
|
||||
onSelectionChange: (files: FileItem[]) => void;
|
||||
currentPath: string;
|
||||
isLoading?: boolean;
|
||||
onPathChange: (path: string) => void;
|
||||
onRefresh: () => void;
|
||||
onUpload?: (files: FileList) => void;
|
||||
@@ -194,7 +188,6 @@ export function FileManagerGrid({
|
||||
onFileOpen,
|
||||
onSelectionChange,
|
||||
currentPath,
|
||||
isLoading,
|
||||
onPathChange,
|
||||
onRefresh,
|
||||
onUpload,
|
||||
@@ -529,20 +522,30 @@ export function FileManagerGrid({
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget && e.button === 0) {
|
||||
e.preventDefault();
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const startX = e.clientX - rect.left;
|
||||
const startY = e.clientY - rect.top;
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (createIntent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName !== "INPUT") {
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.target === e.currentTarget && e.button === 0) {
|
||||
e.preventDefault();
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const startX = e.clientX - rect.left;
|
||||
const startY = e.clientY - rect.top;
|
||||
|
||||
setIsSelecting(true);
|
||||
setSelectionStart({ x: startX, y: startY });
|
||||
setSelectionRect({ x: startX, y: startY, width: 0, height: 0 });
|
||||
setIsSelecting(true);
|
||||
setSelectionStart({ x: startX, y: startY });
|
||||
setSelectionRect({ x: startX, y: startY, width: 0, height: 0 });
|
||||
|
||||
setJustFinishedSelecting(false);
|
||||
}
|
||||
}, []);
|
||||
setJustFinishedSelecting(false);
|
||||
}
|
||||
},
|
||||
[createIntent],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -699,7 +702,7 @@ export function FileManagerGrid({
|
||||
const handleFileClick = (file: FileItem, event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (gridRef.current) {
|
||||
if (gridRef.current && !createIntent) {
|
||||
gridRef.current.focus();
|
||||
}
|
||||
|
||||
@@ -734,7 +737,7 @@ export function FileManagerGrid({
|
||||
};
|
||||
|
||||
const handleGridClick = (event: React.MouseEvent) => {
|
||||
if (gridRef.current) {
|
||||
if (gridRef.current && !createIntent) {
|
||||
gridRef.current.focus();
|
||||
}
|
||||
|
||||
@@ -978,6 +981,7 @@ export function FileManagerGrid({
|
||||
onBlur={handleEditConfirm}
|
||||
className="max-w-[120px] min-w-[60px] w-fit border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50 text-center pointer-events-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
@@ -1100,6 +1104,7 @@ export function FileManagerGrid({
|
||||
onBlur={handleEditConfirm}
|
||||
className="flex-1 min-w-0 max-w-[200px] border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50 pointer-events-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
@@ -1230,8 +1235,6 @@ export function FileManagerGrid({
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
<SimpleLoader visible={isLoading} message={t("common.connecting")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1248,24 +1251,49 @@ function CreateIntentGridItem({
|
||||
const { t } = useTranslation();
|
||||
const [inputName, setInputName] = useState(intent.currentName);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const doneRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, []);
|
||||
const timer = setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [intent.id]);
|
||||
|
||||
const commit = useCallback(
|
||||
(name: string) => {
|
||||
if (doneRef.current) return;
|
||||
doneRef.current = true;
|
||||
if (name) {
|
||||
onConfirm?.(name);
|
||||
} else {
|
||||
onCancel?.();
|
||||
}
|
||||
},
|
||||
[onConfirm, onCancel],
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onConfirm?.(inputName.trim());
|
||||
commit(inputName.trim());
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (doneRef.current) return;
|
||||
doneRef.current = true;
|
||||
onCancel?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group flex flex-col items-center p-3 rounded-none border-2 border-dashed border-accent-brand/60 bg-accent-brand/5">
|
||||
<div
|
||||
className="group flex flex-col items-center p-3 rounded-none border-2 border-dashed border-accent-brand/60 bg-accent-brand/5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-2">
|
||||
{intent.type === "directory" ? (
|
||||
<Folder className="size-10 text-accent-brand" />
|
||||
@@ -1279,7 +1307,7 @@ function CreateIntentGridItem({
|
||||
value={inputName}
|
||||
onChange={(e) => setInputName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => onConfirm?.(inputName.trim())}
|
||||
onBlur={() => commit(inputName.trim())}
|
||||
className="w-full max-w-[120px] border border-accent-brand/60 bg-card px-2 py-1 text-xs text-center rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50"
|
||||
placeholder={
|
||||
intent.type === "directory"
|
||||
@@ -1303,24 +1331,49 @@ function CreateIntentListItem({
|
||||
const { t } = useTranslation();
|
||||
const [inputName, setInputName] = useState(intent.currentName);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const doneRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, []);
|
||||
const timer = setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [intent.id]);
|
||||
|
||||
const commit = useCallback(
|
||||
(name: string) => {
|
||||
if (doneRef.current) return;
|
||||
doneRef.current = true;
|
||||
if (name) {
|
||||
onConfirm?.(name);
|
||||
} else {
|
||||
onCancel?.();
|
||||
}
|
||||
},
|
||||
[onConfirm, onCancel],
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onConfirm?.(inputName.trim());
|
||||
commit(inputName.trim());
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (doneRef.current) return;
|
||||
doneRef.current = true;
|
||||
onCancel?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 px-4 py-2 items-center border-b border-accent-brand/30 bg-accent-brand/5 rounded-none">
|
||||
<div
|
||||
className="grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 px-4 py-2 items-center border-b border-accent-brand/30 bg-accent-brand/5 rounded-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="shrink-0">
|
||||
{intent.type === "directory" ? (
|
||||
@@ -1335,7 +1388,7 @@ function CreateIntentListItem({
|
||||
value={inputName}
|
||||
onChange={(e) => setInputName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => onConfirm?.(inputName.trim())}
|
||||
onBlur={() => commit(inputName.trim())}
|
||||
className="flex-1 min-w-0 max-w-[200px] border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50"
|
||||
placeholder={
|
||||
intent.type === "directory"
|
||||
|
||||
@@ -551,7 +551,7 @@ export function FileManagerSidebar({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full flex flex-col bg-card border-r border-border overflow-hidden">
|
||||
<div className="h-full flex flex-col bg-card overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto thin-scrollbar">
|
||||
{/* ── Recent files ──────────────────────────────────────── */}
|
||||
{renderSection(t("fileManager.recent"), recentItems, (item) =>
|
||||
|
||||
@@ -53,14 +53,20 @@ export function DraggableWindow({
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const [windowStart, setWindowStart] = useState({ x: 0, y: 0 });
|
||||
const [sizeStart, setSizeStart] = useState({ width: 0, height: 0 });
|
||||
const containerBoundsRef = useRef({ width: 0, height: 0 });
|
||||
|
||||
const windowRef = useRef<HTMLDivElement>(null);
|
||||
const titleBarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (targetSize && !isMaximized) {
|
||||
const maxWidth = Math.min(window.innerWidth * 0.9, 1200);
|
||||
const maxHeight = Math.min(window.innerHeight * 0.8, 800);
|
||||
const container = windowRef.current?.offsetParent as HTMLElement | null;
|
||||
const maxWidth = container
|
||||
? Math.min(container.clientWidth * 0.9, 1200)
|
||||
: Math.min(window.innerWidth * 0.9, 1200);
|
||||
const maxHeight = container
|
||||
? Math.min(container.clientHeight * 0.8, 800)
|
||||
: Math.min(window.innerHeight * 0.8, 800);
|
||||
|
||||
let newWidth = Math.min(targetSize.width + 50, maxWidth);
|
||||
let newHeight = Math.min(targetSize.height + 150, maxHeight);
|
||||
@@ -80,8 +86,16 @@ export function DraggableWindow({
|
||||
setSize({ width: newWidth, height: newHeight });
|
||||
|
||||
setPosition({
|
||||
x: Math.max(0, (window.innerWidth - newWidth) / 2),
|
||||
y: Math.max(0, (window.innerHeight - newHeight) / 2),
|
||||
x: Math.max(
|
||||
0,
|
||||
(container ? container.clientWidth : window.innerWidth) / 2 -
|
||||
newWidth / 2,
|
||||
),
|
||||
y: Math.max(
|
||||
0,
|
||||
(container ? container.clientHeight : window.innerHeight) / 2 -
|
||||
newHeight / 2,
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [targetSize, isMaximized, minWidth, minHeight]);
|
||||
@@ -98,6 +112,13 @@ export function DraggableWindow({
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: e.clientX, y: e.clientY });
|
||||
setWindowStart({ x: position.x, y: position.y });
|
||||
|
||||
const container = windowRef.current?.offsetParent as HTMLElement | null;
|
||||
containerBoundsRef.current = {
|
||||
width: container ? container.clientWidth : window.innerWidth,
|
||||
height: container ? container.clientHeight : window.innerHeight,
|
||||
};
|
||||
|
||||
onFocus?.();
|
||||
},
|
||||
[isMaximized, position, onFocus],
|
||||
@@ -112,50 +133,14 @@ export function DraggableWindow({
|
||||
const newX = windowStart.x + deltaX;
|
||||
const newY = windowStart.y + deltaY;
|
||||
|
||||
const windowElement = windowRef.current;
|
||||
let positioningContainer = null;
|
||||
let currentElement = windowElement?.parentElement;
|
||||
|
||||
while (currentElement && currentElement !== document.body) {
|
||||
const computedStyle = window.getComputedStyle(currentElement);
|
||||
const position = computedStyle.position;
|
||||
const transform = computedStyle.transform;
|
||||
|
||||
if (
|
||||
position === "relative" ||
|
||||
position === "absolute" ||
|
||||
position === "fixed" ||
|
||||
transform !== "none"
|
||||
) {
|
||||
positioningContainer = currentElement;
|
||||
break;
|
||||
}
|
||||
|
||||
currentElement = currentElement.parentElement;
|
||||
}
|
||||
|
||||
let maxX, maxY, minX, minY;
|
||||
|
||||
if (positioningContainer) {
|
||||
const containerRect = positioningContainer.getBoundingClientRect();
|
||||
|
||||
maxX = containerRect.width - size.width;
|
||||
maxY = containerRect.height - size.height;
|
||||
minX = 0;
|
||||
minY = 0;
|
||||
} else {
|
||||
maxX = window.innerWidth - size.width;
|
||||
maxY = window.innerHeight - size.height;
|
||||
minX = 0;
|
||||
minY = 0;
|
||||
}
|
||||
|
||||
const constrainedX = Math.max(minX, Math.min(maxX, newX));
|
||||
const constrainedY = Math.max(minY, Math.min(maxY, newY));
|
||||
const { width: containerW, height: containerH } =
|
||||
containerBoundsRef.current;
|
||||
const maxX = containerW - size.width;
|
||||
const maxY = containerH - size.height;
|
||||
|
||||
setPosition({
|
||||
x: constrainedX,
|
||||
y: constrainedY,
|
||||
x: Math.max(0, Math.min(maxX, newX)),
|
||||
y: Math.max(49, Math.min(maxY, newY)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -194,8 +179,10 @@ export function DraggableWindow({
|
||||
}
|
||||
}
|
||||
|
||||
newX = Math.max(0, Math.min(window.innerWidth - newWidth, newX));
|
||||
newY = Math.max(0, Math.min(window.innerHeight - newHeight, newY));
|
||||
const { width: containerW, height: containerH } =
|
||||
containerBoundsRef.current;
|
||||
newX = Math.max(0, Math.min(containerW - newWidth, newX));
|
||||
newY = Math.max(49, Math.min(containerH - newHeight, newY));
|
||||
|
||||
setSize({ width: newWidth, height: newHeight });
|
||||
setPosition({ x: newX, y: newY });
|
||||
@@ -213,7 +200,6 @@ export function DraggableWindow({
|
||||
windowStart,
|
||||
sizeStart,
|
||||
size,
|
||||
position,
|
||||
minWidth,
|
||||
minHeight,
|
||||
resizeDirection,
|
||||
@@ -238,6 +224,13 @@ export function DraggableWindow({
|
||||
setDragStart({ x: e.clientX, y: e.clientY });
|
||||
setWindowStart({ x: position.x, y: position.y });
|
||||
setSizeStart({ width: size.width, height: size.height });
|
||||
|
||||
const container = windowRef.current?.offsetParent as HTMLElement | null;
|
||||
containerBoundsRef.current = {
|
||||
width: container ? container.clientWidth : window.innerWidth,
|
||||
height: container ? container.clientHeight : window.innerHeight,
|
||||
};
|
||||
|
||||
onFocus?.();
|
||||
},
|
||||
[isMaximized, position, size, onFocus],
|
||||
|
||||
@@ -3,8 +3,9 @@ import { Document, Page, pdfjs } from "react-pdf";
|
||||
import { AlertCircle, Download } from "lucide-react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import pdfjsWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.js";
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorkerUrl;
|
||||
|
||||
interface PdfPreviewProps {
|
||||
content: string;
|
||||
@@ -85,10 +86,10 @@ export function PdfPreview({
|
||||
{pdfError ? (
|
||||
<div className="text-center text-muted-foreground p-8">
|
||||
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium mb-2">Cannot load PDF</h3>
|
||||
<p className="text-sm mb-4">
|
||||
There was an error loading this PDF file.
|
||||
</p>
|
||||
<h3 className="text-lg font-medium mb-2">
|
||||
{t("fileManager.cannotLoadPdf")}
|
||||
</h3>
|
||||
<p className="text-sm mb-4">{t("fileManager.pdfLoadError")}</p>
|
||||
{onDownload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -120,7 +121,7 @@ export function PdfPreview({
|
||||
<div className="text-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading PDF...
|
||||
{t("fileManager.loadingPdf")}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@@ -133,7 +134,7 @@ export function PdfPreview({
|
||||
<div className="text-center p-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Loading page...
|
||||
{t("fileManager.loadingPage")}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -168,35 +168,40 @@ export function PermissionsDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-md rounded-none border-border bg-card">
|
||||
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg rounded-none border-border bg-card">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xs font-bold uppercase tracking-widest flex items-center gap-2">
|
||||
<Lock className="size-4 text-accent-brand" />
|
||||
{t("fileManager.changePermissions")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground font-mono">
|
||||
<DialogDescription className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground font-mono break-all">
|
||||
{file.path}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-3 flex flex-col gap-4">
|
||||
<div className="grid grid-cols-4 gap-0 border border-border overflow-hidden">
|
||||
<div className="px-3 py-2 bg-muted/50 border-b border-r border-border text-[10px] font-bold uppercase tracking-widest text-muted-foreground" />
|
||||
{[
|
||||
t("fileManager.read"),
|
||||
t("fileManager.write"),
|
||||
t("fileManager.execute"),
|
||||
].map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className="px-3 py-2 bg-muted/50 border-b border-r border-border last:border-r-0 text-[10px] font-bold uppercase tracking-widest text-muted-foreground text-center"
|
||||
>
|
||||
{h}
|
||||
</div>
|
||||
))}
|
||||
<div className="border border-border overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_64px_64px_64px] bg-muted/50 border-b border-border">
|
||||
<div className="px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground" />
|
||||
{[
|
||||
t("fileManager.read"),
|
||||
t("fileManager.write"),
|
||||
t("fileManager.execute"),
|
||||
].map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className="py-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground text-center border-l border-border"
|
||||
>
|
||||
{h}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{rows.map((row, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<div className="px-3 py-2.5 border-b border-r border-border last:border-b-0 text-xs font-semibold truncate">
|
||||
<div
|
||||
key={i}
|
||||
className={`grid grid-cols-[1fr_64px_64px_64px] ${i < rows.length - 1 ? "border-b border-border" : ""}`}
|
||||
>
|
||||
<div className="px-3 py-3 text-xs font-semibold">
|
||||
{row.label}
|
||||
</div>
|
||||
{[
|
||||
@@ -206,29 +211,28 @@ export function PermissionsDialog({
|
||||
].map((perm, j) => (
|
||||
<div
|
||||
key={j}
|
||||
className="flex items-center justify-center border-b border-r border-border last:border-r-0 py-2.5"
|
||||
className="flex items-center justify-center border-l border-border py-3"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={perm.val}
|
||||
onChange={(e) => perm.set(e.target.checked)}
|
||||
className="accent-[var(--accent-brand)] size-3.5 cursor-pointer"
|
||||
className="accent-[var(--accent-brand)] size-4 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground shrink-0">
|
||||
{t("fileManager.octal")}
|
||||
</span>
|
||||
<Input
|
||||
value={octal}
|
||||
readOnly
|
||||
className="w-20 rounded-none bg-muted/50 border-border text-xs font-mono text-center h-8"
|
||||
maxLength={3}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{t("fileManager.currentPermissions")}: {file.permissions || "—"}
|
||||
|
||||
@@ -3,20 +3,8 @@ import { DraggableWindow } from "./DraggableWindow.tsx";
|
||||
import { Terminal } from "@/features/terminal/Terminal.tsx";
|
||||
import { useWindowManager } from "./WindowManager.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
authType: "password" | "key";
|
||||
credentialId?: number;
|
||||
userId?: number;
|
||||
}
|
||||
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext.tsx";
|
||||
import type { SSHHost } from "@/types/index.ts";
|
||||
|
||||
interface TerminalWindowProps {
|
||||
windowId: string;
|
||||
@@ -96,29 +84,31 @@ export function TerminalWindow({
|
||||
: t("terminal.terminalTitle", { host: hostConfig.name });
|
||||
|
||||
return (
|
||||
<DraggableWindow
|
||||
title={terminalTitle}
|
||||
initialX={initialX}
|
||||
initialY={initialY}
|
||||
initialWidth={800}
|
||||
initialHeight={500}
|
||||
minWidth={600}
|
||||
minHeight={400}
|
||||
onClose={handleClose}
|
||||
onMaximize={handleMaximize}
|
||||
onFocus={handleFocus}
|
||||
onResize={handleResize}
|
||||
isMaximized={currentWindow.isMaximized}
|
||||
zIndex={currentWindow.zIndex}
|
||||
>
|
||||
<Terminal
|
||||
ref={terminalRef}
|
||||
hostConfig={hostConfig}
|
||||
isVisible={!currentWindow.isMinimized}
|
||||
initialPath={initialPath}
|
||||
executeCommand={executeCommand}
|
||||
<CommandHistoryProvider>
|
||||
<DraggableWindow
|
||||
title={terminalTitle}
|
||||
initialX={initialX}
|
||||
initialY={initialY}
|
||||
initialWidth={800}
|
||||
initialHeight={500}
|
||||
minWidth={600}
|
||||
minHeight={400}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
</DraggableWindow>
|
||||
onMaximize={handleMaximize}
|
||||
onFocus={handleFocus}
|
||||
onResize={handleResize}
|
||||
isMaximized={currentWindow.isMaximized}
|
||||
zIndex={currentWindow.zIndex}
|
||||
>
|
||||
<Terminal
|
||||
ref={terminalRef as any}
|
||||
hostConfig={hostConfig as any}
|
||||
isVisible={!currentWindow.isMinimized}
|
||||
initialPath={initialPath}
|
||||
executeCommand={executeCommand}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
</DraggableWindow>
|
||||
</CommandHistoryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,14 +116,19 @@ export function WindowManager({ children }: WindowManagerProps) {
|
||||
return (
|
||||
<WindowManagerContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<div className="window-container">
|
||||
{windows.map((window) => (
|
||||
<div key={window.id}>
|
||||
{typeof window.component === "function"
|
||||
? window.component(window.id)
|
||||
: window.component}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="window-container absolute inset-0 pointer-events-none overflow-hidden"
|
||||
style={{ zIndex: 1000 }}
|
||||
>
|
||||
<div className="relative w-full h-full pointer-events-none">
|
||||
{windows.map((window) => (
|
||||
<div key={window.id} className="pointer-events-auto">
|
||||
{typeof window.component === "function"
|
||||
? window.component(window.id)
|
||||
: window.component}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</WindowManagerContext.Provider>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ interface DragAndDropState {
|
||||
|
||||
interface UseDragAndDropProps {
|
||||
onFilesDropped: (files: FileList) => void;
|
||||
onItemsDropped?: (items: DataTransferItemList) => void;
|
||||
onError?: (error: string) => void;
|
||||
maxFileSize?: number;
|
||||
allowedTypes?: string[];
|
||||
@@ -15,6 +16,7 @@ interface UseDragAndDropProps {
|
||||
|
||||
export function useDragAndDrop({
|
||||
onFilesDropped,
|
||||
onItemsDropped,
|
||||
onError,
|
||||
maxFileSize = 5120,
|
||||
allowedTypes = [],
|
||||
@@ -123,6 +125,16 @@ export function useDragAndDrop({
|
||||
draggedFiles: [],
|
||||
});
|
||||
|
||||
if (onItemsDropped && e.dataTransfer.items?.length > 0) {
|
||||
const hasDirectory = Array.from(e.dataTransfer.items).some(
|
||||
(item) => item.webkitGetAsEntry?.()?.isDirectory,
|
||||
);
|
||||
if (hasDirectory) {
|
||||
onItemsDropped(e.dataTransfer.items);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
|
||||
if (files.length === 0) {
|
||||
@@ -137,7 +149,7 @@ export function useDragAndDrop({
|
||||
|
||||
onFilesDropped(files);
|
||||
},
|
||||
[validateFiles, onFilesDropped, onError],
|
||||
[validateFiles, onFilesDropped, onItemsDropped, onError],
|
||||
);
|
||||
|
||||
const resetDragState = useCallback(() => {
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
GuacamoleDisplay,
|
||||
type GuacamoleDisplayHandle,
|
||||
} from "@/features/guacamole/GuacamoleDisplay.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
import { getGuacamoleTokenFromHost } from "@/main-axios.ts";
|
||||
import { getGuacamoleTokenFromHost, getGuacdStatus } from "@/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import type { SSHHost } from "@/types";
|
||||
|
||||
interface GuacamoleAppProps {
|
||||
hostId?: string;
|
||||
tabId?: string;
|
||||
protocol?: "rdp" | "vnc" | "telnet";
|
||||
}
|
||||
|
||||
const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
|
||||
const GuacamoleApp: React.FC<GuacamoleAppProps> = ({
|
||||
hostId,
|
||||
tabId,
|
||||
protocol,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -18,20 +30,46 @@ const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
|
||||
{(hostConfig, loading) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-40 gap-4">
|
||||
<RefreshCw className="size-8 animate-spin" />
|
||||
<span className="text-sm font-semibold uppercase tracking-widest">
|
||||
{t("common.loading")}
|
||||
</span>
|
||||
<div className="relative w-full h-full">
|
||||
<SimpleLoader visible={true} message={t("common.loading")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-40 gap-4">
|
||||
<AlertCircle className="size-8 text-destructive" />
|
||||
<span className="text-sm font-semibold text-destructive">
|
||||
<div
|
||||
className="flex flex-col items-center justify-center h-full gap-4"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{t("guacamole.hostNotFound")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostId) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center h-full gap-4"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{t("guacamole.hostNotFound")}
|
||||
</span>
|
||||
</div>
|
||||
@@ -40,8 +78,10 @@ const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
|
||||
|
||||
return (
|
||||
<GuacamoleAppInner
|
||||
hostId={parseInt(hostId!, 10)}
|
||||
hostId={parseInt(hostId, 10)}
|
||||
hostConfig={hostConfig}
|
||||
tabId={tabId}
|
||||
protocol={protocol}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
@@ -52,51 +92,154 @@ const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
|
||||
interface GuacamoleAppInnerProps {
|
||||
hostId: number;
|
||||
hostConfig: Pick<SSHHost, "connectionType">;
|
||||
tabId?: string;
|
||||
protocol?: "rdp" | "vnc" | "telnet";
|
||||
}
|
||||
|
||||
const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
|
||||
hostId,
|
||||
hostConfig,
|
||||
tabId,
|
||||
protocol,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const displayRef = useRef<GuacamoleDisplayHandle>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getGuacamoleTokenFromHost(hostId)
|
||||
.then((result) => setToken(result.token))
|
||||
setToken(null);
|
||||
setError(null);
|
||||
getGuacdStatus()
|
||||
.then((status) => {
|
||||
if (status.guacd.status !== "connected") {
|
||||
setError(t("guacamole.guacdUnavailable"));
|
||||
return;
|
||||
}
|
||||
return getGuacamoleTokenFromHost(hostId, protocol);
|
||||
})
|
||||
.then((result) => {
|
||||
if (result) setToken(result.token);
|
||||
})
|
||||
.catch((err) => setError(err?.message || t("guacamole.failedToConnect")));
|
||||
}, [hostId]);
|
||||
}, [hostId, retryCount]);
|
||||
|
||||
const handleReconnect = useCallback(() => {
|
||||
setConnectionError(null);
|
||||
setError(null);
|
||||
setToken(null);
|
||||
setRetryCount((c) => c + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tabId) return;
|
||||
const handler = (e: Event) => {
|
||||
const { tabId: eventTabId } = (e as CustomEvent).detail;
|
||||
if (eventTabId === tabId) handleReconnect();
|
||||
};
|
||||
window.addEventListener("termix:refresh-guacamole", handler);
|
||||
return () =>
|
||||
window.removeEventListener("termix:refresh-guacamole", handler);
|
||||
}, [tabId, handleReconnect]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-40 gap-4">
|
||||
<AlertCircle className="size-8 text-destructive" />
|
||||
<span className="text-sm font-semibold text-destructive">{error}</span>
|
||||
<div
|
||||
className="flex flex-col items-center justify-center h-full gap-4"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<p
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{t("guacamole.connectionFailed")}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs max-w-xs text-center"
|
||||
style={{ color: "var(--foreground-secondary)" }}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={handleReconnect}>
|
||||
<RefreshCw className="size-4 mr-2" />
|
||||
{t("guacamole.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-40 gap-4">
|
||||
<RefreshCw className="size-8 animate-spin" />
|
||||
<span className="text-sm font-semibold uppercase tracking-widest">
|
||||
{t("guacamole.connecting", {
|
||||
type: (hostConfig.connectionType || "remote").toUpperCase(),
|
||||
<div className="relative w-full h-full">
|
||||
<SimpleLoader
|
||||
visible={true}
|
||||
message={t("guacamole.connecting", {
|
||||
type: (
|
||||
protocol ||
|
||||
hostConfig.connectionType ||
|
||||
"remote"
|
||||
).toUpperCase(),
|
||||
})}
|
||||
</span>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const protocol = hostConfig.connectionType as "rdp" | "vnc" | "telnet";
|
||||
const resolvedProtocol = (protocol ?? hostConfig.connectionType) as
|
||||
| "rdp"
|
||||
| "vnc"
|
||||
| "telnet";
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
{connectionError && (
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col items-center justify-center gap-4 z-50"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<p
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{t("guacamole.connectionFailed")}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs max-w-xs text-center"
|
||||
style={{ color: "var(--foreground-secondary)" }}
|
||||
>
|
||||
{connectionError}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={handleReconnect}>
|
||||
<RefreshCw className="size-4 mr-2" />
|
||||
{t("guacamole.reconnect")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<GuacamoleDisplay
|
||||
connectionConfig={{ token, protocol, type: protocol }}
|
||||
key={token}
|
||||
ref={displayRef}
|
||||
connectionConfig={{
|
||||
token,
|
||||
protocol: resolvedProtocol,
|
||||
type: resolvedProtocol,
|
||||
}}
|
||||
isVisible={true}
|
||||
onError={(err) => setConnectionError(err)}
|
||||
/>
|
||||
<GuacamoleToolbar
|
||||
displayRef={displayRef}
|
||||
protocol={resolvedProtocol}
|
||||
onReconnect={handleReconnect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -65,6 +65,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
typeof document === "undefined" ? true : document.hasFocus(),
|
||||
);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
disconnect: () => {
|
||||
@@ -267,13 +268,20 @@ export const GuacamoleDisplay = forwardRef<
|
||||
if (isConnectingRef.current) return;
|
||||
isConnectingRef.current = true;
|
||||
setIsReady(false);
|
||||
setHasError(false);
|
||||
|
||||
let containerWidth = containerRef.current?.clientWidth || 0;
|
||||
let containerHeight = containerRef.current?.clientHeight || 0;
|
||||
// Wait two frames so the container is fully laid out before measuring.
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
let containerWidth = rect?.width || 0;
|
||||
let containerHeight = rect?.height || 0;
|
||||
|
||||
if (containerWidth < 100 || containerHeight < 100) {
|
||||
containerWidth = 1280;
|
||||
containerHeight = 720;
|
||||
containerWidth = window.innerWidth || 1280;
|
||||
containerHeight = window.innerHeight || 720;
|
||||
}
|
||||
|
||||
const wsUrl = await getWebSocketUrl(containerWidth, containerHeight);
|
||||
@@ -303,6 +311,11 @@ export const GuacamoleDisplay = forwardRef<
|
||||
setIsReady(true);
|
||||
};
|
||||
|
||||
const protocol = connectionConfig.protocol ?? connectionConfig.type;
|
||||
if (protocol === "telnet") {
|
||||
setIsReady(true);
|
||||
}
|
||||
|
||||
const mouse = new Guacamole.Mouse(displayElement);
|
||||
const sendMouseState = (state: Guacamole.Mouse.State) => {
|
||||
displayElement.focus({ preventScroll: true });
|
||||
@@ -351,8 +364,15 @@ export const GuacamoleDisplay = forwardRef<
|
||||
case 2:
|
||||
break;
|
||||
case 3:
|
||||
isConnectingRef.current = false;
|
||||
setIsReady(true);
|
||||
onConnect?.();
|
||||
if (containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const w = Math.round(rect.width);
|
||||
const h = Math.round(rect.height);
|
||||
if (w > 0 && h > 0) client.sendSize(w, h);
|
||||
}
|
||||
break;
|
||||
case 4:
|
||||
break;
|
||||
@@ -366,8 +386,10 @@ export const GuacamoleDisplay = forwardRef<
|
||||
};
|
||||
|
||||
client.onerror = (error: Guacamole.Status) => {
|
||||
const errorMessage = error.message || "Connection error";
|
||||
const errorMessage = error.message || t("guacamole.connectionError");
|
||||
setIsReady(false);
|
||||
setHasError(true);
|
||||
isConnectingRef.current = false;
|
||||
onError?.(errorMessage);
|
||||
};
|
||||
|
||||
@@ -388,6 +410,24 @@ export const GuacamoleDisplay = forwardRef<
|
||||
Guacamole.AudioPlayer.getInstance(stream, mimetype);
|
||||
};
|
||||
|
||||
client.onfile = (
|
||||
stream: Guacamole.InputStream,
|
||||
mimetype: string,
|
||||
filename: string,
|
||||
) => {
|
||||
const reader = new Guacamole.BlobReader(stream, mimetype);
|
||||
reader.onend = () => {
|
||||
const blob = reader.getBlob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
stream.sendAck("OK", Guacamole.Status.Code.SUCCESS);
|
||||
};
|
||||
|
||||
client.connect();
|
||||
}, [
|
||||
getWebSocketUrl,
|
||||
@@ -407,11 +447,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
|
||||
if (isVisible && !hasInitiatedRef.current) {
|
||||
hasInitiatedRef.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
if (isMountedRef.current) {
|
||||
connect();
|
||||
}
|
||||
});
|
||||
connect();
|
||||
}
|
||||
}, [isVisible, connect]);
|
||||
|
||||
@@ -475,26 +511,23 @@ export const GuacamoleDisplay = forwardRef<
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
rescaleDisplay(false);
|
||||
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current);
|
||||
resizeTimeoutRef.current = setTimeout(() => {
|
||||
if (clientRef.current && containerRef.current) {
|
||||
const w = containerRef.current.clientWidth;
|
||||
const h = containerRef.current.clientHeight;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const w = Math.round(rect.width);
|
||||
const h = Math.round(rect.height);
|
||||
if (w > 0 && h > 0) clientRef.current.sendSize(w, h);
|
||||
}
|
||||
}, 200);
|
||||
}, 150);
|
||||
});
|
||||
|
||||
resizeObserver.observe(containerRef.current);
|
||||
|
||||
const initialTimeout = setTimeout(() => rescaleDisplay(true), 100);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
clearTimeout(initialTimeout);
|
||||
};
|
||||
}, [rescaleDisplay]);
|
||||
}, []);
|
||||
|
||||
const syncClipboard = useCallback(() => {
|
||||
const client = clientRef.current;
|
||||
@@ -553,7 +586,10 @@ export const GuacamoleDisplay = forwardRef<
|
||||
}}
|
||||
/>
|
||||
|
||||
<SimpleLoader visible={!isReady} message={connectingMessage} />
|
||||
<SimpleLoader
|
||||
visible={!isReady && !hasError}
|
||||
message={connectingMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import {
|
||||
GripVertical,
|
||||
Monitor,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronsLeftRight,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/tooltip.tsx";
|
||||
import type { GuacamoleDisplayHandle } from "@/features/guacamole/GuacamoleDisplay.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface GuacamoleToolbarProps {
|
||||
displayRef: React.RefObject<GuacamoleDisplayHandle>;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
onReconnect: () => void;
|
||||
}
|
||||
|
||||
const MODIFIER_KEYSYMS = {
|
||||
ctrl: 0xffe3,
|
||||
alt: 0xffe9,
|
||||
shift: 0xffe1,
|
||||
win: 0xff67,
|
||||
} as const;
|
||||
|
||||
const FKEY_KEYSYMS = Array.from({ length: 12 }, (_, i) => 0xffbe + i);
|
||||
|
||||
const BTN_BASE =
|
||||
"flex items-center justify-center gap-1 h-7 px-2 text-[10px] font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm whitespace-nowrap select-none";
|
||||
|
||||
const BTN_ICON =
|
||||
"flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm select-none";
|
||||
|
||||
const SEP = "w-px h-5 bg-border mx-0.5 shrink-0";
|
||||
|
||||
function TipBtn({
|
||||
tooltip,
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
tooltip: string;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(BTN_BASE, className)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function TipIconBtn({
|
||||
tooltip,
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
tooltip: string;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(BTN_ICON, className)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
displayRef,
|
||||
protocol,
|
||||
onReconnect,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [position, setPosition] = useState({ x: 0, y: 12 });
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [showFKeys, setShowFKeys] = useState(false);
|
||||
const [stickyKeys, setStickyKeys] = useState<Record<number, boolean>>({
|
||||
[MODIFIER_KEYSYMS.ctrl]: false,
|
||||
[MODIFIER_KEYSYMS.alt]: false,
|
||||
[MODIFIER_KEYSYMS.shift]: false,
|
||||
[MODIFIER_KEYSYMS.win]: false,
|
||||
});
|
||||
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
const dragOriginRef = useRef({ mouseX: 0, mouseY: 0, posX: 0, posY: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = toolbarRef.current;
|
||||
if (!el) return;
|
||||
const parent = el.offsetParent as HTMLElement | null;
|
||||
if (!parent) return;
|
||||
const parentW = parent.clientWidth;
|
||||
const toolbarW = el.offsetWidth;
|
||||
setPosition((p) => ({ ...p, x: Math.max(0, (parentW - toolbarW) / 2) }));
|
||||
}, [collapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!isDraggingRef.current) return;
|
||||
const parent = toolbarRef.current?.offsetParent as HTMLElement | null;
|
||||
const parentW = parent?.clientWidth ?? Infinity;
|
||||
const parentH = parent?.clientHeight ?? Infinity;
|
||||
const toolbarW = toolbarRef.current?.offsetWidth ?? 0;
|
||||
const toolbarH = toolbarRef.current?.offsetHeight ?? 0;
|
||||
|
||||
const dx = e.clientX - dragOriginRef.current.mouseX;
|
||||
const dy = e.clientY - dragOriginRef.current.mouseY;
|
||||
setPosition({
|
||||
x: Math.max(
|
||||
0,
|
||||
Math.min(dragOriginRef.current.posX + dx, parentW - toolbarW),
|
||||
),
|
||||
y: Math.max(
|
||||
0,
|
||||
Math.min(dragOriginRef.current.posY + dy, parentH - toolbarH),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
isDraggingRef.current = false;
|
||||
setIsDragging(false);
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
const startDrag = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
dragOriginRef.current = {
|
||||
mouseX: e.clientX,
|
||||
mouseY: e.clientY,
|
||||
posX: position.x,
|
||||
posY: position.y,
|
||||
};
|
||||
document.body.style.userSelect = "none";
|
||||
setIsDragging(true);
|
||||
},
|
||||
[position],
|
||||
);
|
||||
|
||||
const releaseStickyKeys = useCallback(() => {
|
||||
const display = displayRef.current;
|
||||
if (!display) return;
|
||||
setStickyKeys((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const [ksStr, active] of Object.entries(prev)) {
|
||||
if (active) {
|
||||
display.sendKey(Number(ksStr), false);
|
||||
next[Number(ksStr)] = false;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [displayRef]);
|
||||
|
||||
const sendCombo = useCallback(
|
||||
(...keysyms: number[]) => {
|
||||
const display = displayRef.current;
|
||||
if (!display) return;
|
||||
for (const k of keysyms) display.sendKey(k, true);
|
||||
for (const k of [...keysyms].reverse()) display.sendKey(k, false);
|
||||
releaseStickyKeys();
|
||||
},
|
||||
[displayRef, releaseStickyKeys],
|
||||
);
|
||||
|
||||
const toggleStickyKey = useCallback(
|
||||
(keysym: number) => {
|
||||
const display = displayRef.current;
|
||||
if (!display) return;
|
||||
setStickyKeys((prev) => {
|
||||
const isActive = prev[keysym];
|
||||
display.sendKey(keysym, !isActive);
|
||||
return { ...prev, [keysym]: !isActive };
|
||||
});
|
||||
},
|
||||
[displayRef],
|
||||
);
|
||||
|
||||
const isRdpVnc = protocol === "rdp" || protocol === "vnc";
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
zIndex: 20,
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
style={containerStyle}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{collapsed ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center bg-background/85 backdrop-blur-sm border border-border shadow-lg rounded-sm overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={startDrag}
|
||||
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="size-3" />
|
||||
</button>
|
||||
<div className="w-px h-4 bg-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<Monitor className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t("guacamole.toolbar.expand")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className="flex items-center bg-background/85 backdrop-blur-sm border border-border shadow-lg rounded-sm px-0.5 py-0.5 gap-0">
|
||||
{/* Drag handle */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={startDrag}
|
||||
className="flex items-center justify-center h-7 px-1 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t("guacamole.toolbar.dragHandle")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* System combos — RDP/VNC only */}
|
||||
{isRdpVnc && (
|
||||
<>
|
||||
<div className={SEP} />
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.ctrlAltDel")}
|
||||
onClick={() => sendCombo(0xffe3, 0xffe9, 0xffff)}
|
||||
>
|
||||
CAD
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.winL")}
|
||||
onClick={() => sendCombo(0xff67, 0x006c)}
|
||||
>
|
||||
Win+L
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.winKey")}
|
||||
onClick={() => sendCombo(0xff67)}
|
||||
>
|
||||
Win
|
||||
</TipBtn>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Sticky modifiers — RDP/VNC only */}
|
||||
{isRdpVnc && (
|
||||
<>
|
||||
<div className={SEP} />
|
||||
{(
|
||||
[
|
||||
[
|
||||
"ctrl",
|
||||
MODIFIER_KEYSYMS.ctrl,
|
||||
t("guacamole.toolbar.ctrl"),
|
||||
],
|
||||
["alt", MODIFIER_KEYSYMS.alt, t("guacamole.toolbar.alt")],
|
||||
[
|
||||
"shift",
|
||||
MODIFIER_KEYSYMS.shift,
|
||||
t("guacamole.toolbar.shift"),
|
||||
],
|
||||
["win", MODIFIER_KEYSYMS.win, t("guacamole.toolbar.win")],
|
||||
] as [string, number, string][]
|
||||
).map(([key, ks, label]) => (
|
||||
<Tooltip key={key}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleStickyKey(ks)}
|
||||
className={cn(
|
||||
BTN_BASE,
|
||||
stickyKeys[ks] &&
|
||||
"bg-primary/15 text-primary border border-primary/30",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{stickyKeys[ks]
|
||||
? t("guacamole.toolbar.stickyActive", { key: label })
|
||||
: t("guacamole.toolbar.stickyInactive", { key: label })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Function key toggle */}
|
||||
<div className={SEP} />
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.fnToggle")}
|
||||
onClick={() => setShowFKeys((v) => !v)}
|
||||
className={cn(
|
||||
showFKeys &&
|
||||
"bg-primary/15 text-primary border border-primary/30",
|
||||
)}
|
||||
>
|
||||
Fn
|
||||
</TipBtn>
|
||||
|
||||
{/* F1-F12 row */}
|
||||
{showFKeys &&
|
||||
FKEY_KEYSYMS.map((ks, i) => (
|
||||
<TipBtn
|
||||
key={ks}
|
||||
tooltip={`F${i + 1}`}
|
||||
onClick={() => sendCombo(ks)}
|
||||
>
|
||||
F{i + 1}
|
||||
</TipBtn>
|
||||
))}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className={SEP} />
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.esc")}
|
||||
onClick={() => sendCombo(0xff1b)}
|
||||
>
|
||||
Esc
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.tab")}
|
||||
onClick={() => sendCombo(0xff09)}
|
||||
>
|
||||
Tab
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.home")}
|
||||
onClick={() => sendCombo(0xff50)}
|
||||
>
|
||||
Home
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.end")}
|
||||
onClick={() => sendCombo(0xff57)}
|
||||
>
|
||||
End
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.pageUp")}
|
||||
onClick={() => sendCombo(0xff55)}
|
||||
>
|
||||
PgUp
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.pageDown")}
|
||||
onClick={() => sendCombo(0xff56)}
|
||||
>
|
||||
PgDn
|
||||
</TipBtn>
|
||||
|
||||
{/* Arrow cluster */}
|
||||
<div className="flex flex-col ml-0.5">
|
||||
<div className="flex justify-center">
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.arrowUp")}
|
||||
onClick={() => sendCombo(0xff52)}
|
||||
>
|
||||
<ChevronUp className="size-3" />
|
||||
</TipIconBtn>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.arrowLeft")}
|
||||
onClick={() => sendCombo(0xff51)}
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
</TipIconBtn>
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.arrowDown")}
|
||||
onClick={() => sendCombo(0xff54)}
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</TipIconBtn>
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.toolbar.arrowRight")}
|
||||
onClick={() => sendCombo(0xff53)}
|
||||
>
|
||||
<ChevronRight className="size-3" />
|
||||
</TipIconBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collapse */}
|
||||
<div className="w-px h-5 bg-border mx-0.5 shrink-0" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(true)}
|
||||
className={cn(BTN_ICON)}
|
||||
>
|
||||
<ChevronsLeftRight className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t("guacamole.toolbar.collapse")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -405,6 +405,11 @@ function ServerStatsInner({
|
||||
|
||||
try {
|
||||
if (!totpVerified) {
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "stats_connecting",
|
||||
message: `Connecting to ${currentHostConfig.username}@${currentHostConfig.ip}:${currentHostConfig.port}`,
|
||||
});
|
||||
const result = await startMetricsPolling(currentHostConfig.id);
|
||||
|
||||
if (cancelled) return;
|
||||
@@ -459,10 +464,10 @@ function ServerStatsInner({
|
||||
|
||||
if (data) {
|
||||
setMetrics(data);
|
||||
setServerStatus("online");
|
||||
if (!hasExistingMetrics) {
|
||||
setIsLoadingMetrics(false);
|
||||
logServerActivity();
|
||||
setTimeout(() => clearLogs(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,17 +572,24 @@ function ServerStatsInner({
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
|
||||
if (hasConnectionError) {
|
||||
setHasConnectionError(false);
|
||||
clearLogs();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
const res = await getServerStatusById(currentHostConfig.id);
|
||||
setServerStatus(res?.status === "online" ? "online" : "offline");
|
||||
const data = await getServerMetricsById(currentHostConfig.id);
|
||||
if (data) setMetrics(data);
|
||||
setShowStatsUI(true);
|
||||
if (data) {
|
||||
setMetrics(data);
|
||||
setShowStatsUI(true);
|
||||
}
|
||||
} catch {
|
||||
setServerStatus("offline");
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
@@ -598,7 +610,7 @@ function ServerStatsInner({
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden flex flex-col">
|
||||
{!totpRequired && !isLoadingMetrics && (
|
||||
{!totpRequired && !isLoadingMetrics && !hasConnectionError && (
|
||||
<div className="mx-3 mt-3 flex items-center justify-between border border-border bg-card px-3 py-3 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
|
||||
@@ -606,16 +618,6 @@ function ServerStatsInner({
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg md:text-2xl font-bold">{title}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`size-2 rounded-full ${serverStatus === "online" ? "bg-accent-brand" : "bg-destructive"}`}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{serverStatus === "online"
|
||||
? t("serverStats.online")
|
||||
: t("serverStats.offline")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0">
|
||||
@@ -735,16 +737,19 @@ function ServerStatsInner({
|
||||
showStatsUI &&
|
||||
!isLoadingMetrics &&
|
||||
!metrics &&
|
||||
serverStatus === "offline" && (
|
||||
serverStatus === "offline" &&
|
||||
!hasConnectionError && (
|
||||
<div className="flex-1 flex items-center justify-center py-20">
|
||||
<div className="text-center opacity-40">
|
||||
<Server className="size-16 mx-auto mb-4" />
|
||||
<p className="text-xl font-bold uppercase tracking-widest">
|
||||
{t("serverStats.serverOffline")}
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{t("serverStats.cannotFetchMetrics")}
|
||||
</p>
|
||||
<div className="text-center">
|
||||
<div className="opacity-40">
|
||||
<Server className="size-16 mx-auto mb-4" />
|
||||
<p className="text-xl font-bold uppercase tracking-widest">
|
||||
{t("serverStats.serverOffline")}
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{t("serverStats.cannotFetchMetrics")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -777,7 +782,7 @@ function ServerStatsInner({
|
||||
/>
|
||||
<ConnectionLog
|
||||
isConnecting={isLoadingMetrics}
|
||||
isConnected={serverStatus === "online"}
|
||||
isConnected={serverStatus === "online" && !hasConnectionError}
|
||||
hasConnectionError={hasConnectionError}
|
||||
position={hasConnectionError ? "top" : "bottom"}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ServerStats } from "@/features/server-stats/ServerStats.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
|
||||
@@ -7,6 +8,7 @@ interface ServerStatsAppProps {
|
||||
}
|
||||
|
||||
const ServerStatsApp: React.FC<ServerStatsAppProps> = ({ hostId }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
@@ -15,7 +17,9 @@ const ServerStatsApp: React.FC<ServerStatsAppProps> = ({ hostId }) => {
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading host...</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t("hosts.loadingHost")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -25,7 +29,7 @@ const ServerStatsApp: React.FC<ServerStatsAppProps> = ({ hostId }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">Host not found</p>
|
||||
<p className="text-red-500 mb-4">{t("hosts.hostNotFound")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,36 +21,40 @@ function Sparkline({
|
||||
current ?? 0,
|
||||
].slice(-20);
|
||||
|
||||
if (points.length < 2) return null;
|
||||
|
||||
const w = 300;
|
||||
const h = 48;
|
||||
const max = Math.max(...points, 1);
|
||||
const coords = points.map((v, i) => {
|
||||
const x = (i / (points.length - 1)) * w;
|
||||
const y = h - (v / max) * h;
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
const d = `M ${coords.join(" L ")}`;
|
||||
const fill = `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z`;
|
||||
const hasData = points.length >= 2;
|
||||
const max = hasData ? Math.max(...points, 1) : 1;
|
||||
const coords = hasData
|
||||
? points.map((v, i) => {
|
||||
const x = (i / (points.length - 1)) * w;
|
||||
const y = h - (v / max) * h;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
: [];
|
||||
|
||||
const d = hasData ? `M ${coords.join(" L ")}` : "";
|
||||
const fill = hasData ? `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z` : "";
|
||||
|
||||
return (
|
||||
<div className="h-12 md:h-16 w-full mt-2 bg-muted/20 border border-border/50 relative overflow-hidden">
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full"
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d={fill} fill="currentColor" className="text-accent-brand/10" />
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-accent-brand/60"
|
||||
/>
|
||||
</svg>
|
||||
{hasData && (
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full"
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d={fill} fill="currentColor" className="text-accent-brand/10" />
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-accent-brand/60"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,36 +20,40 @@ function Sparkline({
|
||||
current ?? 0,
|
||||
].slice(-20);
|
||||
|
||||
if (points.length < 2) return null;
|
||||
|
||||
const w = 300;
|
||||
const h = 48;
|
||||
const max = Math.max(...points, 1);
|
||||
const coords = points.map((v, i) => {
|
||||
const x = (i / (points.length - 1)) * w;
|
||||
const y = h - (v / max) * h;
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
const d = `M ${coords.join(" L ")}`;
|
||||
const fill = `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z`;
|
||||
const hasData = points.length >= 2;
|
||||
const max = hasData ? Math.max(...points, 1) : 1;
|
||||
const coords = hasData
|
||||
? points.map((v, i) => {
|
||||
const x = (i / (points.length - 1)) * w;
|
||||
const y = h - (v / max) * h;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
: [];
|
||||
|
||||
const d = hasData ? `M ${coords.join(" L ")}` : "";
|
||||
const fill = hasData ? `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z` : "";
|
||||
|
||||
return (
|
||||
<div className="h-12 md:h-16 w-full mt-2 bg-muted/20 border border-border/50 relative overflow-hidden">
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full"
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d={fill} fill="currentColor" className="text-accent-brand/10" />
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-accent-brand/60"
|
||||
/>
|
||||
</svg>
|
||||
{hasData && (
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full"
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d={fill} fill="currentColor" className="text-accent-brand/10" />
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-accent-brand/60"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ export function FirewallWidget({ metrics }: FirewallWidgetProps) {
|
||||
</span>
|
||||
)}
|
||||
{firewall && firewall.chains.length > 0 ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1 overflow-y-auto max-h-[320px]">
|
||||
{firewall.chains.map((chain) => (
|
||||
<ChainSection key={chain.name} chain={chain} />
|
||||
))}
|
||||
|
||||
@@ -46,40 +46,42 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
|
||||
{t("serverStats.noRecentLoginData")}
|
||||
</span>
|
||||
) : (
|
||||
allLogins.map((login, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center justify-between p-2 border ${login.status === "success" ? "border-border bg-muted/30" : "border-destructive/30 bg-destructive/5"}`}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{login.status === "failed" ? (
|
||||
<UserX className="size-3 text-destructive" />
|
||||
) : (
|
||||
<UserCheck className="size-3 text-accent-brand" />
|
||||
)}
|
||||
<span
|
||||
className={`text-xs font-bold ${login.status === "failed" ? "text-destructive" : ""}`}
|
||||
>
|
||||
{login.user}
|
||||
<div className="flex flex-col gap-2 overflow-y-auto max-h-[300px]">
|
||||
{allLogins.map((login, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center justify-between p-2 border ${login.status === "success" ? "border-border bg-muted/30" : "border-destructive/30 bg-destructive/5"}`}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{login.status === "failed" ? (
|
||||
<UserX className="size-3 text-destructive" />
|
||||
) : (
|
||||
<UserCheck className="size-3 text-accent-brand" />
|
||||
)}
|
||||
<span
|
||||
className={`text-xs font-bold ${login.status === "failed" ? "text-destructive" : ""}`}
|
||||
>
|
||||
{login.user}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{login.ip}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span
|
||||
className={`text-[9px] font-bold uppercase px-1.5 py-px border ${login.status === "success" ? "border-accent-brand/40 text-accent-brand bg-accent-brand/10" : "border-destructive/40 text-destructive"}`}
|
||||
>
|
||||
{login.status}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{new Date(login.time).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{login.ip}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span
|
||||
className={`text-[9px] font-bold uppercase px-1.5 py-px border ${login.status === "success" ? "border-accent-brand/40 text-accent-brand bg-accent-brand/10" : "border-destructive/40 text-destructive"}`}
|
||||
>
|
||||
{login.status}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{new Date(login.time).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
@@ -20,36 +20,40 @@ function Sparkline({
|
||||
current ?? 0,
|
||||
].slice(-20);
|
||||
|
||||
if (points.length < 2) return null;
|
||||
|
||||
const w = 300;
|
||||
const h = 48;
|
||||
const max = Math.max(...points, 1);
|
||||
const coords = points.map((v, i) => {
|
||||
const x = (i / (points.length - 1)) * w;
|
||||
const y = h - (v / max) * h;
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
const d = `M ${coords.join(" L ")}`;
|
||||
const fill = `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z`;
|
||||
const hasData = points.length >= 2;
|
||||
const max = hasData ? Math.max(...points, 1) : 1;
|
||||
const coords = hasData
|
||||
? points.map((v, i) => {
|
||||
const x = (i / (points.length - 1)) * w;
|
||||
const y = h - (v / max) * h;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
: [];
|
||||
|
||||
const d = hasData ? `M ${coords.join(" L ")}` : "";
|
||||
const fill = hasData ? `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z` : "";
|
||||
|
||||
return (
|
||||
<div className="h-12 md:h-16 w-full mt-2 bg-muted/20 border border-border/50 relative overflow-hidden">
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full"
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d={fill} fill="currentColor" className="text-accent-brand/10" />
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-accent-brand/60"
|
||||
/>
|
||||
</svg>
|
||||
{hasData && (
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full"
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d={fill} fill="currentColor" className="text-accent-brand/10" />
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-accent-brand/60"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,34 +38,36 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) {
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
interfaces.map((iface, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col p-2 border border-border bg-muted/30 gap-1"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`size-1.5 rounded-full ${iface.state === "UP" ? "bg-accent-brand" : "bg-muted-foreground"}`}
|
||||
/>
|
||||
<span className="text-sm font-bold font-mono">
|
||||
{iface.name}
|
||||
<div className="flex flex-col gap-2 overflow-y-auto max-h-[260px]">
|
||||
{interfaces.map((iface, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col p-2 border border-border bg-muted/30 gap-1"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`size-1.5 rounded-full ${iface.state === "UP" ? "bg-accent-brand" : "bg-muted-foreground"}`}
|
||||
/>
|
||||
<span className="text-sm font-bold font-mono">
|
||||
{iface.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold px-1.5 py-px border border-border text-muted-foreground uppercase">
|
||||
{iface.state}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold px-1.5 py-px border border-border text-muted-foreground uppercase">
|
||||
{iface.state}
|
||||
</span>
|
||||
<div className="flex justify-between text-[10px] font-mono text-muted-foreground">
|
||||
<span>{iface.ip}</span>
|
||||
{(iface.rx || iface.tx) && (
|
||||
<span>
|
||||
↓ {iface.rx ?? "—"} / ↑ {iface.tx ?? "—"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] font-mono text-muted-foreground">
|
||||
<span>{iface.ip}</span>
|
||||
{(iface.rx || iface.tx) && (
|
||||
<span>
|
||||
↓ {iface.rx ?? "—"} / ↑ {iface.tx ?? "—"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
@@ -14,15 +14,17 @@ function PortRow({ port }: { port: ListeningPort }) {
|
||||
addr === "0.0.0.0" || addr === "*" || addr === "::" ? "*" : addr;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 text-xs font-mono py-1 border-b border-border/50 last:border-0 min-w-0">
|
||||
<span className="text-accent-brand font-bold">{port.localPort}</span>
|
||||
<span className="text-muted-foreground">
|
||||
<div className="grid grid-cols-4 text-xs font-mono py-1 border-b border-border/50 last:border-0 min-w-0 overflow-hidden">
|
||||
<span className="text-accent-brand font-bold truncate">
|
||||
{port.localPort}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate">
|
||||
{port.protocol.toUpperCase()}
|
||||
</span>
|
||||
<span className="font-semibold truncate">
|
||||
{port.process ?? (port.pid ? `PID:${port.pid}` : "—")}
|
||||
</span>
|
||||
<span className="text-right text-muted-foreground">
|
||||
<span className="text-right text-muted-foreground truncate">
|
||||
{formatAddress(port.localAddress)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -41,7 +43,7 @@ export function PortsWidget({ metrics }: PortsWidgetProps) {
|
||||
title={t("serverStats.ports.title")}
|
||||
icon={<Unplug className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 py-1">
|
||||
<div className="flex flex-col gap-1.5 py-1 overflow-x-hidden">
|
||||
<div className="grid grid-cols-4 text-[10px] text-muted-foreground font-bold uppercase pb-1 border-b border-border min-w-0">
|
||||
<span>{t("serverStats.ports.port")}</span>
|
||||
<span>{t("serverStats.ports.protocol")}</span>
|
||||
@@ -53,12 +55,14 @@ export function PortsWidget({ metrics }: PortsWidgetProps) {
|
||||
{t("serverStats.ports.noData")}
|
||||
</span>
|
||||
) : (
|
||||
ports.map((port, i) => (
|
||||
<PortRow
|
||||
key={`${port.protocol}-${port.localPort}-${i}`}
|
||||
port={port}
|
||||
/>
|
||||
))
|
||||
<div className="overflow-y-auto max-h-[300px]">
|
||||
{ports.map((port, i) => (
|
||||
<PortRow
|
||||
key={`${port.protocol}-${port.localPort}-${i}`}
|
||||
port={port}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
@@ -45,19 +45,21 @@ export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
|
||||
<span className="text-xs">{t("serverStats.noProcessesFound")}</span>
|
||||
</div>
|
||||
) : (
|
||||
topProcesses.map((proc, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid grid-cols-4 text-xs font-mono py-1 border-b border-border/50 last:border-0 min-w-0"
|
||||
>
|
||||
<span className="text-muted-foreground">{proc.pid}</span>
|
||||
<span className="text-accent-brand font-bold">{proc.cpu}%</span>
|
||||
<span>{proc.mem}%</span>
|
||||
<span className="truncate font-semibold" title={proc.command}>
|
||||
{proc.command.split("/").pop()}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
<div className="overflow-y-auto max-h-[280px]">
|
||||
{topProcesses.map((proc, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid grid-cols-4 text-xs font-mono py-1 border-b border-border/50 last:border-0 min-w-0"
|
||||
>
|
||||
<span className="text-muted-foreground">{proc.pid}</span>
|
||||
<span className="text-accent-brand font-bold">{proc.cpu}%</span>
|
||||
<span>{proc.mem}%</span>
|
||||
<span className="truncate font-semibold" title={proc.command}>
|
||||
{proc.command.split("/").pop()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
@@ -53,6 +53,46 @@ import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
|
||||
// Background/foreground per UI theme for "Termix Default" — must match index.css
|
||||
const TERMIX_DEFAULT_COLORS: Record<
|
||||
string,
|
||||
{ background: string; foreground: string }
|
||||
> = {
|
||||
dark: { background: "#0c0d0b", foreground: "#fafafa" },
|
||||
light: { background: "#ffffff", foreground: "#111210" },
|
||||
dracula: { background: "#282a36", foreground: "#f8f8f2" },
|
||||
catppuccin: { background: "#1e1e2e", foreground: "#cdd6f4" },
|
||||
nord: { background: "#2e3440", foreground: "#eceff4" },
|
||||
solarized: { background: "#002b36", foreground: "#839496" },
|
||||
"tokyo-night": { background: "#1a1b26", foreground: "#a9b1d6" },
|
||||
"one-dark": { background: "#282c34", foreground: "#abb2bf" },
|
||||
gruvbox: { background: "#282828", foreground: "#ebdbb2" },
|
||||
};
|
||||
|
||||
function resolveTermixThemeColors(activeTheme: string, appTheme: string) {
|
||||
if (activeTheme !== "termix") {
|
||||
return (
|
||||
TERMINAL_THEMES[activeTheme]?.colors || TERMINAL_THEMES.termixDark.colors
|
||||
);
|
||||
}
|
||||
let resolvedUiTheme = appTheme;
|
||||
if (appTheme === "system") {
|
||||
resolvedUiTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
const uiColors =
|
||||
TERMIX_DEFAULT_COLORS[resolvedUiTheme] ?? TERMIX_DEFAULT_COLORS.dark;
|
||||
const base = TERMINAL_THEMES.termixDark.colors;
|
||||
return {
|
||||
...base,
|
||||
background: uiColors.background,
|
||||
foreground: uiColors.foreground,
|
||||
cursor: uiColors.foreground,
|
||||
cursorAccent: uiColors.background,
|
||||
};
|
||||
}
|
||||
|
||||
interface HostConfig {
|
||||
id?: number;
|
||||
instanceId?: string;
|
||||
@@ -130,27 +170,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
DEFAULT_TERMINAL_CONFIG.theme,
|
||||
};
|
||||
|
||||
const isDarkMode =
|
||||
appTheme === "dark" ||
|
||||
appTheme === "dracula" ||
|
||||
appTheme === "gentlemansChoice" ||
|
||||
appTheme === "midnightEspresso" ||
|
||||
appTheme === "catppuccinMocha" ||
|
||||
(appTheme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
|
||||
let themeColors;
|
||||
const activeTheme = previewTheme || config.theme;
|
||||
|
||||
if (activeTheme === "termix") {
|
||||
themeColors = isDarkMode
|
||||
? TERMINAL_THEMES.termixDark.colors
|
||||
: TERMINAL_THEMES.termixLight.colors;
|
||||
} else {
|
||||
themeColors =
|
||||
TERMINAL_THEMES[activeTheme]?.colors ||
|
||||
TERMINAL_THEMES.termixDark.colors;
|
||||
}
|
||||
const themeColors = resolveTermixThemeColors(activeTheme, appTheme);
|
||||
const backgroundColor = themeColors.background;
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const webSocketRef = useRef<WebSocket | null>(null);
|
||||
@@ -220,6 +241,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}>;
|
||||
} | null>(null);
|
||||
const tmuxSessionNameRef = useRef<string | null>(null);
|
||||
const [isTmuxAttached, setIsTmuxAttached] = useState(false);
|
||||
const tmuxCopyModeHintShownRef = useRef(false);
|
||||
|
||||
const isVisibleRef = useRef<boolean>(false);
|
||||
@@ -688,9 +710,28 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
},
|
||||
fit: () => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
if (!fitAddonRef.current || !terminal || isFittingRef.current) return;
|
||||
isFittingRef.current = true;
|
||||
try {
|
||||
fitAddonRef.current.fit();
|
||||
if (terminal.cols > 0 && terminal.rows > 0) {
|
||||
const lastSize = lastFittedSizeRef.current;
|
||||
if (
|
||||
!lastSize ||
|
||||
lastSize.cols !== terminal.cols ||
|
||||
lastSize.rows !== terminal.rows
|
||||
) {
|
||||
scheduleNotify(terminal.cols, terminal.rows);
|
||||
lastFittedSizeRef.current = {
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows,
|
||||
};
|
||||
}
|
||||
}
|
||||
setIsFitted(true);
|
||||
} finally {
|
||||
isFittingRef.current = false;
|
||||
}
|
||||
},
|
||||
sendInput: (data: string) => {
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
@@ -849,6 +890,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
if (isEmbeddedMode()) {
|
||||
baseWsUrl = "ws://127.0.0.1:30002";
|
||||
const storedJwt = localStorage.getItem("jwt");
|
||||
if (storedJwt) {
|
||||
baseWsUrl += `?token=${encodeURIComponent(storedJwt)}`;
|
||||
}
|
||||
} else if (!configuredUrl) {
|
||||
console.error("No configured server URL available for Electron SSH");
|
||||
setIsConnected(false);
|
||||
@@ -864,6 +909,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/$/, "");
|
||||
baseWsUrl = `${wsProtocol}${wsHost}/ssh/websocket/`;
|
||||
const storedJwt = localStorage.getItem("jwt");
|
||||
if (storedJwt) {
|
||||
baseWsUrl += `?token=${encodeURIComponent(storedJwt)}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
baseWsUrl = `${getBasePath()}/ssh/websocket/`;
|
||||
@@ -935,7 +984,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
currentHostConfigRef.current = hostConfig;
|
||||
|
||||
const persistenceEnabled =
|
||||
localStorage.getItem("enableTerminalSessionPersistence") === "true";
|
||||
localStorage.getItem("enableTerminalSessionPersistence") !== "false";
|
||||
const tabId = hostConfig.instanceId
|
||||
? `${hostConfig.id}_${hostConfig.instanceId}`
|
||||
: `${hostConfig.id}_${Date.now()}`;
|
||||
@@ -1206,7 +1255,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
shouldNotReconnectRef.current = true;
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
if (wasConnectedRef.current) {
|
||||
if (msg.graceful) {
|
||||
wasConnectedRef.current = false;
|
||||
if (onClose) onClose();
|
||||
} else if (wasConnectedRef.current) {
|
||||
wasConnectedRef.current = false;
|
||||
setShowDisconnectedOverlay(true);
|
||||
} else if (!connectionErrorRef.current) {
|
||||
@@ -1433,8 +1485,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
} else if (msg.type === "sessionCreated") {
|
||||
sessionIdRef.current = msg.sessionId;
|
||||
const persistenceEnabled =
|
||||
localStorage.getItem("enableTerminalSessionPersistence") ===
|
||||
"true";
|
||||
localStorage.getItem("enableTerminalSessionPersistence") !==
|
||||
"false";
|
||||
if (persistenceEnabled && hostConfig.instanceId) {
|
||||
const tabId = `${hostConfig.id}_${hostConfig.instanceId}`;
|
||||
localStorage.setItem(`termix_session_${tabId}`, msg.sessionId);
|
||||
@@ -1511,6 +1563,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const sessionName =
|
||||
typeof msg.sessionName === "string" ? msg.sessionName : "";
|
||||
tmuxSessionNameRef.current = sessionName || "(active)";
|
||||
setIsTmuxAttached(true);
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "connection",
|
||||
@@ -1534,6 +1587,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
stage: "connection",
|
||||
message: t("terminal.tmuxUnavailable"),
|
||||
});
|
||||
} else if (msg.type === "tmux_detached") {
|
||||
tmuxSessionNameRef.current = null;
|
||||
setIsTmuxAttached(false);
|
||||
toast.info(t("terminal.tmuxDetached"), { duration: 3000 });
|
||||
} else if (msg.type === "connection_log") {
|
||||
if (msg.data) {
|
||||
addLog({
|
||||
@@ -1559,6 +1616,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
setIsConnected(false);
|
||||
isConnectingRef.current = false;
|
||||
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
if (pongTimeoutRef.current) {
|
||||
clearTimeout(pongTimeoutRef.current);
|
||||
pongTimeoutRef.current = null;
|
||||
@@ -1649,6 +1711,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
setIsConnecting(false);
|
||||
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
if (totpTimeoutRef.current) {
|
||||
clearTimeout(totpTimeoutRef.current);
|
||||
totpTimeoutRef.current = null;
|
||||
@@ -1786,18 +1853,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
...hostConfig.terminalConfig,
|
||||
};
|
||||
|
||||
let themeColors;
|
||||
const activeTheme = previewTheme || config.theme;
|
||||
|
||||
if (activeTheme === "termix") {
|
||||
themeColors = isDarkMode
|
||||
? TERMINAL_THEMES.termixDark.colors
|
||||
: TERMINAL_THEMES.termixLight.colors;
|
||||
} else {
|
||||
themeColors =
|
||||
TERMINAL_THEMES[activeTheme]?.colors ||
|
||||
TERMINAL_THEMES.termixDark.colors;
|
||||
}
|
||||
const themeColors = resolveTermixThemeColors(activeTheme, appTheme);
|
||||
|
||||
const fontConfig = TERMINAL_FONTS.find(
|
||||
(f) => f.value === config.fontFamily,
|
||||
@@ -1811,7 +1868,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
terminal.options.fontSize = config.fontSize;
|
||||
terminal.options.fontFamily = fontFamily;
|
||||
terminal.options.rightClickSelectsWord = config.rightClickSelectsWord;
|
||||
terminal.options.fastScrollModifier = config.fastScrollModifier;
|
||||
terminal.options.fastScrollSensitivity = config.fastScrollSensitivity;
|
||||
terminal.options.minimumContrastRatio = config.minimumContrastRatio;
|
||||
terminal.options.letterSpacing = config.letterSpacing;
|
||||
@@ -1854,13 +1910,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
// Refresh terminal to apply new theme colors to existing buffer content
|
||||
hardRefresh();
|
||||
}, [
|
||||
terminal,
|
||||
hostConfig.terminalConfig,
|
||||
previewTheme,
|
||||
isDarkMode,
|
||||
isFitted,
|
||||
]);
|
||||
}, [terminal, hostConfig.terminalConfig, previewTheme, appTheme, isFitted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !xtermRef.current) return;
|
||||
@@ -1875,18 +1925,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
);
|
||||
const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
|
||||
|
||||
let themeColors;
|
||||
const activeTheme = previewTheme || config.theme;
|
||||
|
||||
if (activeTheme === "termix") {
|
||||
themeColors = isDarkMode
|
||||
? TERMINAL_THEMES.termixDark.colors
|
||||
: TERMINAL_THEMES.termixLight.colors;
|
||||
} else {
|
||||
themeColors =
|
||||
TERMINAL_THEMES[activeTheme]?.colors ||
|
||||
TERMINAL_THEMES.termixDark.colors;
|
||||
}
|
||||
const themeColors = resolveTermixThemeColors(activeTheme, appTheme);
|
||||
|
||||
// Set initial options before opening the terminal
|
||||
terminal.options = {
|
||||
@@ -1897,11 +1937,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
fontFamily,
|
||||
allowTransparency: true, // MUST be set before open()
|
||||
convertEol: false,
|
||||
windowsMode: false,
|
||||
macOptionIsMeta: false,
|
||||
macOptionClickForcesSelection: false,
|
||||
rightClickSelectsWord: config.rightClickSelectsWord,
|
||||
fastScrollModifier: config.fastScrollModifier,
|
||||
fastScrollSensitivity: config.fastScrollSensitivity,
|
||||
allowProposedApi: true,
|
||||
minimumContrastRatio: config.minimumContrastRatio,
|
||||
@@ -1938,7 +1976,13 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const clipboardProvider = new RobustClipboardProvider();
|
||||
const clipboardAddon = new ClipboardAddon(undefined, clipboardProvider);
|
||||
const unicode11Addon = new Unicode11Addon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
const webLinksAddon = new WebLinksAddon((_event, uri) => {
|
||||
const url =
|
||||
uri.startsWith("http://") || uri.startsWith("https://")
|
||||
? uri
|
||||
: `https://${uri}`;
|
||||
window.open(url, "_blank");
|
||||
});
|
||||
|
||||
fitAddonRef.current = fitAddon;
|
||||
terminal.loadAddon(fitAddon);
|
||||
@@ -1950,15 +1994,35 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
terminal.open(xtermRef.current);
|
||||
|
||||
terminal.attachCustomWheelEventHandler((ev) => {
|
||||
const cfg = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...hostConfig.terminalConfig,
|
||||
};
|
||||
const mod = cfg.fastScrollModifier;
|
||||
const modHeld =
|
||||
(mod === "alt" && ev.altKey) ||
|
||||
(mod === "ctrl" && ev.ctrlKey) ||
|
||||
(mod === "shift" && ev.shiftKey);
|
||||
if (modHeld) {
|
||||
const lines = Math.round(
|
||||
(Math.abs(ev.deltaY) / 100) * (cfg.fastScrollSensitivity ?? 5),
|
||||
);
|
||||
terminal.scrollLines(ev.deltaY > 0 ? lines : -lines);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal.cols < 10 || terminal.rows < 3) {
|
||||
// Double-rAF ensures layout is fully settled (fonts, flexbox, etc.) before
|
||||
// committing the fitted size, preventing the "terminal too short" glitch.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
setIsFitted(true);
|
||||
});
|
||||
} else {
|
||||
setIsFitted(true);
|
||||
}
|
||||
});
|
||||
|
||||
const element = xtermRef.current;
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
@@ -2101,7 +2165,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
|
||||
const persistenceEnabled =
|
||||
localStorage.getItem("enableTerminalSessionPersistence") === "true";
|
||||
localStorage.getItem("enableTerminalSessionPersistence") !==
|
||||
"false";
|
||||
if (
|
||||
!persistenceEnabled &&
|
||||
sessionIdRef.current &&
|
||||
@@ -2410,32 +2475,28 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
if (terminal.cols < 10 || terminal.rows < 3) {
|
||||
setIsConnecting(true);
|
||||
fitAddonRef.current?.fit();
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal.cols > 0 && terminal.rows > 0) {
|
||||
setIsConnecting(true);
|
||||
fitAddonRef.current?.fit();
|
||||
scheduleNotify(terminal.cols, terminal.rows);
|
||||
connectToHost(terminal.cols, terminal.rows);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
fitAddonRef.current?.fit();
|
||||
requestAnimationFrame(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal.cols > 0 && terminal.rows > 0) {
|
||||
scheduleNotify(terminal.cols, terminal.rows);
|
||||
connectToHost(terminal.cols, terminal.rows);
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [terminal, hostConfig.id, isVisible, isConnected, isConnecting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !fitAddonRef.current || !isVisible) return;
|
||||
if (!terminal || !fitAddonRef.current) return;
|
||||
|
||||
if (!isVisible) {
|
||||
lastFittedSizeRef.current = null;
|
||||
lastSentSizeRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const fitTimeoutId = setTimeout(() => {
|
||||
if (!isFittingRef.current && terminal.cols > 0 && terminal.rows > 0) {
|
||||
@@ -2444,7 +2505,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
requestAnimationFrame(() => terminal.focus());
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(fitTimeoutId);
|
||||
}, [terminal, isVisible, splitScreen, isConnecting]);
|
||||
@@ -2470,6 +2531,22 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}}
|
||||
/>
|
||||
|
||||
{isTmuxAttached && isConnected && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({ type: "tmux_detach" }),
|
||||
);
|
||||
}
|
||||
}}
|
||||
title={t("terminal.tmuxDetach")}
|
||||
className="absolute top-2 right-2 z-[110] px-2 py-1 text-xs rounded bg-black/60 text-white/70 hover:text-white hover:bg-black/80 transition-colors"
|
||||
>
|
||||
tmux:detach
|
||||
</button>
|
||||
)}
|
||||
|
||||
<SimpleLoader
|
||||
visible={isConnecting && !isConnectionLogExpanded}
|
||||
message={t("terminal.connecting")}
|
||||
@@ -2478,9 +2555,12 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
{showDisconnectedOverlay && !isConnecting && (
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col items-center justify-center gap-4 z-10"
|
||||
className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-[120]"
|
||||
style={{ backgroundColor }}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("terminal.connectionLost")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -2502,7 +2582,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
{t("terminal.reconnect")}
|
||||
</Button>
|
||||
{onClose && (
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("terminal.closeTab")}
|
||||
</Button>
|
||||
)}
|
||||
@@ -2513,7 +2593,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
<ConnectionLog
|
||||
isConnecting={isConnecting}
|
||||
isConnected={isConnected}
|
||||
hasConnectionError={hasConnectionError}
|
||||
hasConnectionError={hasConnectionError && !showDisconnectedOverlay}
|
||||
position={hasConnectionError ? "top" : "bottom"}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Terminal } from "@/features/terminal/Terminal.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
|
||||
@@ -7,6 +8,7 @@ interface TerminalAppProps {
|
||||
}
|
||||
|
||||
const TerminalApp: React.FC<TerminalAppProps> = ({ hostId }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
@@ -15,7 +17,9 @@ const TerminalApp: React.FC<TerminalAppProps> = ({ hostId }) => {
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading host...</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t("hosts.loadingHost")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -25,7 +29,7 @@ const TerminalApp: React.FC<TerminalAppProps> = ({ hostId }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">Host not found</p>
|
||||
<p className="text-red-500 mb-4">{t("hosts.hostNotFound")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TERMINAL_THEMES, TERMINAL_FONTS } from "@/lib/terminal-themes.ts";
|
||||
import { useTheme } from "@/components/theme-provider.tsx";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { TERMINAL_THEMES, TERMINAL_FONTS } from "@/lib/terminal-themes";
|
||||
|
||||
interface TerminalPreviewProps {
|
||||
theme: string;
|
||||
@@ -18,7 +18,7 @@ export function TerminalPreview({
|
||||
cursorStyle = "bar",
|
||||
cursorBlink = true,
|
||||
letterSpacing = 0,
|
||||
lineHeight = 1.2,
|
||||
lineHeight = 1.0,
|
||||
}: TerminalPreviewProps) {
|
||||
const { theme: appTheme } = useTheme();
|
||||
|
||||
@@ -31,80 +31,79 @@ export function TerminalPreview({
|
||||
: "termixLight"
|
||||
: theme;
|
||||
|
||||
const colors = TERMINAL_THEMES[resolvedTheme]?.colors;
|
||||
const fontFallback =
|
||||
TERMINAL_FONTS.find((f) => f.value === fontFamily)?.fallback ||
|
||||
TERMINAL_FONTS[0].fallback;
|
||||
|
||||
return (
|
||||
<div className="border border-input rounded-md overflow-hidden">
|
||||
<div className="border border-input overflow-hidden">
|
||||
<div
|
||||
className="p-4 font-mono text-sm"
|
||||
className="p-3 font-mono"
|
||||
style={{
|
||||
fontSize: `${fontSize}px`,
|
||||
fontFamily:
|
||||
TERMINAL_FONTS.find((f) => f.value === fontFamily)?.fallback ||
|
||||
TERMINAL_FONTS[0].fallback,
|
||||
fontFamily: fontFallback,
|
||||
letterSpacing: `${letterSpacing}px`,
|
||||
lineHeight,
|
||||
background:
|
||||
TERMINAL_THEMES[resolvedTheme]?.colors.background ||
|
||||
"var(--bg-base)",
|
||||
color:
|
||||
TERMINAL_THEMES[resolvedTheme]?.colors.foreground ||
|
||||
"var(--foreground)",
|
||||
background: colors?.background || "var(--bg-base)",
|
||||
color: colors?.foreground || "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.green }}>
|
||||
user@termix
|
||||
<span style={{ color: colors?.green }}>deploy@web-01</span>
|
||||
<span style={{ color: colors?.brightBlack }}>:</span>
|
||||
<span style={{ color: colors?.blue }}>~</span>
|
||||
<span style={{ color: colors?.brightBlack }}>$</span>
|
||||
<span> ls -la</span>
|
||||
</div>
|
||||
<div style={{ color: colors?.brightBlack }}>total 48</div>
|
||||
<div>
|
||||
<span style={{ color: colors?.cyan }}>drwxr-xr-x</span>
|
||||
<span style={{ color: colors?.brightBlack }}>
|
||||
{" "}
|
||||
5 deploy deploy 4096 May 1 09:12{" "}
|
||||
</span>
|
||||
<span>:</span>
|
||||
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>
|
||||
~
|
||||
</span>
|
||||
<span>$ ls -la</span>
|
||||
<span style={{ color: colors?.blue }}>.</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>
|
||||
drwxr-xr-x
|
||||
</span>
|
||||
<span> 5 user </span>
|
||||
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.cyan }}>
|
||||
docs
|
||||
<span style={{ color: colors?.cyan }}>drwxr-xr-x</span>
|
||||
<span style={{ color: colors?.brightBlack }}>
|
||||
{" "}
|
||||
3 root root 4096 Apr 15 18:44{" "}
|
||||
</span>
|
||||
<span style={{ color: colors?.blue }}>..</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.green }}>
|
||||
-rwxr-xr-x
|
||||
</span>
|
||||
<span> 1 user </span>
|
||||
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.green }}>
|
||||
script.sh
|
||||
<span style={{ color: colors?.cyan }}>-rw-r--r--</span>
|
||||
<span style={{ color: colors?.brightBlack }}>
|
||||
{" "}
|
||||
1 deploy deploy 220 Apr 15 18:44{" "}
|
||||
</span>
|
||||
<span>.bash_logout</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>-rw-r--r--</span>
|
||||
<span> 1 user </span>
|
||||
<span>README.md</span>
|
||||
<span style={{ color: colors?.cyan }}>-rwxr-xr-x</span>
|
||||
<span style={{ color: colors?.brightBlack }}>
|
||||
{" "}
|
||||
1 deploy deploy 8192 May 1 08:55{" "}
|
||||
</span>
|
||||
<span style={{ color: colors?.green }}>deploy.sh</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.green }}>
|
||||
user@termix
|
||||
</span>
|
||||
<span>:</span>
|
||||
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>
|
||||
~
|
||||
</span>
|
||||
<span>$ </span>
|
||||
<div className="flex items-center gap-0.5 mt-0.5">
|
||||
<span style={{ color: colors?.green }}>deploy@web-01</span>
|
||||
<span style={{ color: colors?.brightBlack }}>:</span>
|
||||
<span style={{ color: colors?.blue }}>~</span>
|
||||
<span style={{ color: colors?.brightBlack }}>$</span>
|
||||
<span> </span>
|
||||
<span
|
||||
className="inline-block"
|
||||
style={{
|
||||
width: cursorStyle === "block" ? "0.6em" : "0.1em",
|
||||
height:
|
||||
cursorStyle === "underline"
|
||||
? "0.15em"
|
||||
: cursorStyle === "bar"
|
||||
? `${fontSize}px`
|
||||
: `${fontSize}px`,
|
||||
background:
|
||||
TERMINAL_THEMES[resolvedTheme]?.colors.cursor || "#f7f7f7",
|
||||
animation: cursorBlink ? "blink 1s step-end infinite" : "none",
|
||||
width: cursorStyle === "block" ? "0.6em" : "0.12em",
|
||||
height: cursorStyle === "underline" ? "0.12em" : `${fontSize}px`,
|
||||
background: colors?.cursor || colors?.foreground || "#f7f7f7",
|
||||
animation: cursorBlink
|
||||
? "termPreviewBlink 1s step-end infinite"
|
||||
: "none",
|
||||
verticalAlign:
|
||||
cursorStyle === "underline" ? "bottom" : "text-bottom",
|
||||
}}
|
||||
@@ -112,7 +111,7 @@ export function TerminalPreview({
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes blink {
|
||||
@keyframes termPreviewBlink {
|
||||
0%, 49% { opacity: 1; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { TunnelViewer } from "@/features/tunnel/TunnelViewer.tsx";
|
||||
import {
|
||||
getSSHHosts,
|
||||
subscribeTunnelStatuses,
|
||||
connectTunnel,
|
||||
disconnectTunnel,
|
||||
cancelTunnel,
|
||||
logActivity,
|
||||
} from "@/main-axios.ts";
|
||||
import type {
|
||||
SSHHost,
|
||||
TunnelConnection,
|
||||
TunnelStatus,
|
||||
SSHTunnelProps,
|
||||
} from "@/types/index";
|
||||
|
||||
export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
||||
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
|
||||
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
|
||||
const [tunnelStatuses, setTunnelStatuses] = useState<
|
||||
Record<string, TunnelStatus>
|
||||
>({});
|
||||
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const prevVisibleHostRef = React.useRef<SSHHost | null>(null);
|
||||
const activityLoggedRef = React.useRef(false);
|
||||
const activityLoggingRef = React.useRef(false);
|
||||
|
||||
const haveTunnelConnectionsChanged = (
|
||||
a: TunnelConnection[] = [],
|
||||
b: TunnelConnection[] = [],
|
||||
): boolean => {
|
||||
if (a.length !== b.length) return true;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const x = a[i];
|
||||
const y = b[i];
|
||||
if (
|
||||
x.sourcePort !== y.sourcePort ||
|
||||
x.endpointPort !== y.endpointPort ||
|
||||
x.endpointHost !== y.endpointHost ||
|
||||
x.maxRetries !== y.maxRetries ||
|
||||
x.retryInterval !== y.retryInterval ||
|
||||
x.autoStart !== y.autoStart
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const fetchHosts = useCallback(async () => {
|
||||
const hostsData = await getSSHHosts();
|
||||
setAllHosts(hostsData);
|
||||
const nextVisible = filterHostKey
|
||||
? hostsData.filter((h) => {
|
||||
const key =
|
||||
h.name && h.name.trim() !== "" ? h.name : `${h.username}@${h.ip}`;
|
||||
return key === filterHostKey;
|
||||
})
|
||||
: hostsData;
|
||||
|
||||
const prev = prevVisibleHostRef.current;
|
||||
const curr = nextVisible[0] ?? null;
|
||||
let changed = false;
|
||||
if (!prev && curr) changed = true;
|
||||
else if (prev && !curr) changed = true;
|
||||
else if (prev && curr) {
|
||||
if (
|
||||
prev.id !== curr.id ||
|
||||
prev.name !== curr.name ||
|
||||
prev.ip !== curr.ip ||
|
||||
prev.port !== curr.port ||
|
||||
prev.username !== curr.username ||
|
||||
haveTunnelConnectionsChanged(
|
||||
prev.tunnelConnections,
|
||||
curr.tunnelConnections,
|
||||
)
|
||||
) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
setVisibleHosts(nextVisible);
|
||||
prevVisibleHostRef.current = curr;
|
||||
}
|
||||
}, [filterHostKey]);
|
||||
|
||||
const logTunnelActivity = async (host: SSHHost) => {
|
||||
if (!host?.id || activityLoggedRef.current || activityLoggingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
activityLoggingRef.current = true;
|
||||
activityLoggedRef.current = true;
|
||||
|
||||
try {
|
||||
const hostName = host.name || `${host.username}@${host.ip}`;
|
||||
await logActivity("tunnel", host.id, hostName);
|
||||
} catch (err) {
|
||||
console.warn("Failed to log tunnel activity:", err);
|
||||
activityLoggedRef.current = false;
|
||||
} finally {
|
||||
activityLoggingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
const interval = setInterval(fetchHosts, 5000);
|
||||
|
||||
const handleHostsChanged = () => {
|
||||
fetchHosts();
|
||||
};
|
||||
window.addEventListener(
|
||||
"ssh-hosts:changed",
|
||||
handleHostsChanged as EventListener,
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener(
|
||||
"ssh-hosts:changed",
|
||||
handleHostsChanged as EventListener,
|
||||
);
|
||||
};
|
||||
}, [fetchHosts]);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeTunnelStatuses(setTunnelStatuses, () => {
|
||||
// The view remains usable if the stream reconnects or is unavailable.
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleHosts.length > 0 && visibleHosts[0]) {
|
||||
logTunnelActivity(visibleHosts[0]);
|
||||
}
|
||||
}, [visibleHosts.length > 0 ? visibleHosts[0]?.id : null]);
|
||||
|
||||
const handleTunnelAction = async (
|
||||
action: "connect" | "disconnect" | "cancel",
|
||||
host: SSHHost,
|
||||
tunnelIndex: number,
|
||||
) => {
|
||||
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||
const tunnelName = `${host.id}::${tunnelIndex}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
|
||||
|
||||
setTunnelActions((prev) => ({ ...prev, [tunnelName]: true }));
|
||||
|
||||
try {
|
||||
if (action === "connect") {
|
||||
const endpointHost = allHosts.find(
|
||||
(h) =>
|
||||
h.name === tunnel.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnel.endpointHost,
|
||||
);
|
||||
|
||||
const tunnelConfig = {
|
||||
name: tunnelName,
|
||||
scope: tunnel.scope || "s2s",
|
||||
mode: tunnel.mode || tunnel.tunnelType || "remote",
|
||||
bindHost: tunnel.bindHost,
|
||||
targetHost: tunnel.targetHost,
|
||||
tunnelType:
|
||||
tunnel.tunnelType ||
|
||||
(tunnel.mode === "local" || tunnel.mode === "remote"
|
||||
? tunnel.mode
|
||||
: "remote"),
|
||||
sourceHostId: host.id,
|
||||
tunnelIndex: tunnelIndex,
|
||||
hostName: host.name || `${host.username}@${host.ip}`,
|
||||
sourceIP: host.ip,
|
||||
sourceSSHPort: host.port,
|
||||
sourceUsername: host.username,
|
||||
sourcePassword:
|
||||
host.authType === "password" ? host.password : undefined,
|
||||
sourceAuthMethod: host.authType,
|
||||
sourceSSHKey: host.authType === "key" ? host.key : undefined,
|
||||
sourceKeyPassword:
|
||||
host.authType === "key" ? host.keyPassword : undefined,
|
||||
sourceKeyType: host.authType === "key" ? host.keyType : undefined,
|
||||
sourceCredentialId: host.credentialId,
|
||||
sourceUserId: host.userId,
|
||||
endpointHost: tunnel.endpointHost,
|
||||
endpointIP: endpointHost?.ip,
|
||||
endpointSSHPort: endpointHost?.port,
|
||||
endpointUsername: endpointHost?.username,
|
||||
endpointPassword:
|
||||
endpointHost?.authType === "password"
|
||||
? endpointHost.password
|
||||
: undefined,
|
||||
endpointAuthMethod: endpointHost?.authType,
|
||||
endpointSSHKey:
|
||||
endpointHost?.authType === "key" ? endpointHost.key : undefined,
|
||||
endpointKeyPassword:
|
||||
endpointHost?.authType === "key"
|
||||
? endpointHost.keyPassword
|
||||
: undefined,
|
||||
endpointKeyType:
|
||||
endpointHost?.authType === "key" ? endpointHost.keyType : undefined,
|
||||
endpointCredentialId: endpointHost?.credentialId,
|
||||
endpointUserId: endpointHost?.userId,
|
||||
sourcePort: tunnel.sourcePort,
|
||||
endpointPort: tunnel.endpointPort,
|
||||
maxRetries: tunnel.maxRetries,
|
||||
retryInterval: tunnel.retryInterval * 1000,
|
||||
autoStart: tunnel.autoStart,
|
||||
isPinned: host.pin,
|
||||
useSocks5: host.useSocks5,
|
||||
socks5Host: host.socks5Host,
|
||||
socks5Port: host.socks5Port,
|
||||
socks5Username: host.socks5Username,
|
||||
socks5Password: host.socks5Password,
|
||||
socks5ProxyChain: host.socks5ProxyChain,
|
||||
};
|
||||
await connectTunnel(tunnelConfig);
|
||||
} else if (action === "disconnect") {
|
||||
await disconnectTunnel(tunnelName);
|
||||
} else if (action === "cancel") {
|
||||
await cancelTunnel(tunnelName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Tunnel action failed:", {
|
||||
action,
|
||||
tunnelName,
|
||||
hostId: host.id,
|
||||
tunnelIndex,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
fullError: error,
|
||||
});
|
||||
} finally {
|
||||
setTunnelActions((prev) => ({ ...prev, [tunnelName]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TunnelViewer
|
||||
hosts={visibleHosts}
|
||||
tunnelStatuses={tunnelStatuses}
|
||||
tunnelActions={tunnelActions}
|
||||
onTunnelAction={handleTunnelAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,55 @@
|
||||
import React from "react";
|
||||
import { TunnelManager } from "@/features/tunnel/TunnelManager.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
import { TunnelTab } from "@/features/tunnel/TunnelTab.tsx";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { SSHHost } from "@/types";
|
||||
|
||||
interface TunnelAppProps {
|
||||
hostId?: string;
|
||||
}
|
||||
|
||||
function sshHostToMinimalHost(h: SSHHost): Host {
|
||||
return {
|
||||
id: String(h.id),
|
||||
name: h.name,
|
||||
ip: h.ip,
|
||||
port: h.port,
|
||||
username: h.username,
|
||||
folder: h.folder ?? "",
|
||||
online: false,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
lastAccess: "",
|
||||
tags: h.tags ?? [],
|
||||
pin: h.pin ?? false,
|
||||
authType: h.authType,
|
||||
enableTerminal: h.enableTerminal ?? false,
|
||||
enableTunnel: h.enableTunnel ?? false,
|
||||
enableFileManager: h.enableFileManager ?? false,
|
||||
enableDocker: h.enableDocker ?? false,
|
||||
enableSsh: h.enableSsh ?? true,
|
||||
enableRdp: h.enableRdp ?? false,
|
||||
enableVnc: h.enableVnc ?? false,
|
||||
enableTelnet: h.enableTelnet ?? false,
|
||||
sshPort: h.sshPort ?? h.port,
|
||||
rdpPort: h.rdpPort ?? 3389,
|
||||
vncPort: h.vncPort ?? 5900,
|
||||
telnetPort: h.telnetPort ?? 23,
|
||||
serverTunnels: [],
|
||||
quickActions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const TunnelApp: React.FC<TunnelAppProps> = ({ hostId }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading host...</p>
|
||||
</div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,20 +57,15 @@ const TunnelApp: React.FC<TunnelAppProps> = ({ hostId }) => {
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">Host not found</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground">{t("hosts.hostNotFound")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TunnelManager
|
||||
hostConfig={hostConfig}
|
||||
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
<TunnelTab
|
||||
label={hostConfig.name}
|
||||
host={sshHostToMinimalHost(hostConfig)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -88,28 +88,28 @@ export function TunnelInlineControls({
|
||||
|
||||
const statusClass =
|
||||
kind === "connected"
|
||||
? "text-green-600 dark:text-green-400 bg-green-500/10 border-green-500/20"
|
||||
? "text-accent-brand border-accent-brand/40 bg-accent-brand/10"
|
||||
: kind === "connecting"
|
||||
? "text-blue-600 dark:text-blue-400 bg-blue-500/10 border-blue-500/20"
|
||||
? "text-blue-400 border-blue-400/40 bg-blue-400/10"
|
||||
: kind === "error"
|
||||
? "text-red-600 dark:text-red-400 bg-red-500/10 border-red-500/20"
|
||||
: "text-muted-foreground bg-muted/30 border-border";
|
||||
? "text-destructive border-destructive/40 bg-destructive/10"
|
||||
: "text-muted-foreground border-border bg-muted/30";
|
||||
|
||||
const statusIcon =
|
||||
kind === "connected" ? (
|
||||
<Wifi className="h-3 w-3" />
|
||||
<Wifi className="size-3" />
|
||||
) : kind === "connecting" ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : kind === "error" ? (
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
<AlertCircle className="size-3" />
|
||||
) : (
|
||||
<WifiOff className="h-3 w-3" />
|
||||
<WifiOff className="size-3" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<span
|
||||
className={`inline-flex h-8 items-center gap-1.5 rounded-md border px-2 text-xs font-medium ${statusClass}`}
|
||||
className={`inline-flex h-8 items-center gap-1.5 border px-2 text-[10px] font-bold uppercase tracking-wide ${statusClass}`}
|
||||
title={title}
|
||||
>
|
||||
{statusIcon}
|
||||
@@ -123,7 +123,7 @@ export function TunnelInlineControls({
|
||||
disabled
|
||||
className="h-8 px-3 text-xs text-muted-foreground border-border"
|
||||
>
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
<Loader2 className="size-3 mr-1 animate-spin" />
|
||||
{isDisconnected ? t("tunnels.start") : t("tunnels.stop")}
|
||||
</Button>
|
||||
) : isDisconnected ? (
|
||||
@@ -134,9 +134,9 @@ export function TunnelInlineControls({
|
||||
onClick={onStart}
|
||||
disabled={startDisabled}
|
||||
title={startDisabled ? startDisabledReason : undefined}
|
||||
className="h-8 px-3 text-xs text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50"
|
||||
className="h-8 px-3 text-xs text-accent-brand border-accent-brand/40 hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
<Play className="size-3 mr-1" />
|
||||
{t("tunnels.start")}
|
||||
</Button>
|
||||
) : (
|
||||
@@ -145,9 +145,9 @@ export function TunnelInlineControls({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onStop}
|
||||
className="h-8 px-3 text-xs text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50"
|
||||
className="h-8 px-3 text-xs text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Square className="h-3 w-3 mr-1" />
|
||||
<Square className="size-3 mr-1" />
|
||||
{t("tunnels.stop")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import React from "react";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Tunnel } from "@/features/tunnel/Tunnel.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getSSHHosts } from "@/main-axios.ts";
|
||||
import { useTabsSafe } from "@/shell/TabContext.tsx";
|
||||
|
||||
interface HostConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
username: string;
|
||||
folder?: string;
|
||||
enableFileManager?: boolean;
|
||||
tunnelConnections?: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TunnelManagerProps {
|
||||
hostConfig?: HostConfig;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function TunnelManager({
|
||||
hostConfig,
|
||||
title,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
embedded = false,
|
||||
}: TunnelManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { tabs, addTab, setCurrentTab, updateTab } = useTabsSafe();
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
const isElectron =
|
||||
typeof window !== "undefined" && window.electronAPI?.isElectron === true;
|
||||
|
||||
const openC2SPresets = React.useCallback(() => {
|
||||
const profileTab = tabs.find((tab) => tab.type === "user_profile");
|
||||
if (profileTab) {
|
||||
updateTab(profileTab.id, {
|
||||
initialTab: "c2s-tunnels",
|
||||
_updateTimestamp: Date.now(),
|
||||
});
|
||||
setCurrentTab(profileTab.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = addTab({
|
||||
type: "user_profile",
|
||||
title: t("profile.title"),
|
||||
initialTab: "c2s-tunnels",
|
||||
});
|
||||
setCurrentTab(id);
|
||||
}, [addTab, setCurrentTab, t, tabs, updateTab]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchLatestHostConfig = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch {
|
||||
// Silently handle error
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchLatestHostConfig();
|
||||
|
||||
const handleHostsChanged = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch {
|
||||
// Silently handle error
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
return () =>
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = 8;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
|
||||
: {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
};
|
||||
|
||||
const containerClass = embedded
|
||||
? "h-full w-full text-foreground overflow-hidden bg-transparent"
|
||||
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-bold text-lg truncate">
|
||||
{currentHostConfig?.folder} / {title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
{isElectron && (
|
||||
<Button size="sm" variant="outline" onClick={openC2SPresets}>
|
||||
{t("tunnels.manageClientTunnels")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0 p-1">
|
||||
{currentHostConfig?.tunnelConnections &&
|
||||
currentHostConfig.tunnelConnections.length > 0 ? (
|
||||
<div className="rounded-lg h-full overflow-hidden flex flex-col min-h-0">
|
||||
<Tunnel
|
||||
filterHostKey={
|
||||
currentHostConfig?.name &&
|
||||
currentHostConfig.name.trim() !== ""
|
||||
? currentHostConfig.name
|
||||
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-foreground-subtle text-lg">
|
||||
{t("tunnels.noTunnelsConfigured")}
|
||||
</p>
|
||||
<p className="text-foreground-subtle text-sm mt-2">
|
||||
{t("tunnels.configureTunnelsInHostSettings")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user