mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
fix: general qol additions and new analytics/telemetrics feature
This commit is contained in:
@@ -738,6 +738,7 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "custom_themes", "TEXT");
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS dashboard_service_links (
|
||||
|
||||
@@ -816,6 +816,7 @@ export const userPreferences = sqliteTable("user_preferences", {
|
||||
hiddenRailTabs: text("hidden_rail_tabs"),
|
||||
compactHostView: integer("compact_host_view", { mode: "boolean" }),
|
||||
statusColorScheme: text("status_color_scheme"),
|
||||
customThemes: text("custom_themes"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -28,7 +28,7 @@ export type AcmeSettings = {
|
||||
enabled: boolean;
|
||||
domain: string;
|
||||
email: string;
|
||||
challengeType: "http-webroot" | "dns-cloudflare";
|
||||
challengeType: "http-webroot" | "dns-cloudflare" | "manual";
|
||||
cloudflareToken: string;
|
||||
lastIssuedAt: string | null;
|
||||
certStatus: "none" | "valid" | "expiring" | "expired";
|
||||
@@ -166,7 +166,7 @@ export function registerAcmeSSLRoutes(
|
||||
* type: string
|
||||
* challengeType:
|
||||
* type: string
|
||||
* enum: [http-webroot, dns-cloudflare]
|
||||
* enum: [http-webroot, dns-cloudflare, manual]
|
||||
* cloudflareToken:
|
||||
* type: string
|
||||
* responses:
|
||||
@@ -414,4 +414,158 @@ export function registerAcmeSSLRoutes(
|
||||
res.status(500).json({ error: `Certificate request failed: ${message}` });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/manual-ssl-upload:
|
||||
* post:
|
||||
* summary: Upload a manual/custom SSL certificate and key (admin only)
|
||||
* description: Validates and installs a user-supplied PEM certificate and private key as the active Termix SSL certificate.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* certificate:
|
||||
* type: string
|
||||
* privateKey:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Certificate uploaded and installed successfully.
|
||||
* 400:
|
||||
* description: Invalid or missing certificate/key.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Certificate installation failed.
|
||||
*/
|
||||
router.post("/manual-ssl-upload", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const actor = await getAdminActor(userId);
|
||||
try {
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
const { certificate, privateKey } = req.body;
|
||||
|
||||
if (
|
||||
typeof certificate !== "string" ||
|
||||
typeof privateKey !== "string" ||
|
||||
!certificate.includes("BEGIN CERTIFICATE") ||
|
||||
!privateKey.includes("PRIVATE KEY")
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "A valid PEM certificate and private key are required" });
|
||||
}
|
||||
|
||||
await fs.mkdir(SSL_DIR, { recursive: true });
|
||||
|
||||
const tmpCertFile = path.join(SSL_DIR, ".manual-upload.crt.tmp");
|
||||
const tmpKeyFile = path.join(SSL_DIR, ".manual-upload.key.tmp");
|
||||
|
||||
try {
|
||||
await fs.writeFile(tmpCertFile, certificate, { mode: 0o644 });
|
||||
await fs.writeFile(tmpKeyFile, privateKey, { mode: 0o600 });
|
||||
|
||||
try {
|
||||
execFileSync("openssl", ["x509", "-in", tmpCertFile, "-noout"], {
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync(
|
||||
"openssl",
|
||||
["pkey", "-in", tmpKeyFile, "-noout", "-check"],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
} catch {
|
||||
return res.status(400).json({
|
||||
error: "The provided certificate or private key is not valid PEM data",
|
||||
});
|
||||
}
|
||||
|
||||
const certPubkey = execFileSync(
|
||||
"openssl",
|
||||
["x509", "-in", tmpCertFile, "-noout", "-pubkey"],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
const keyPubkey = execFileSync(
|
||||
"openssl",
|
||||
["pkey", "-in", tmpKeyFile, "-pubout"],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
|
||||
if (!certPubkey.equals(keyPubkey)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "The certificate and private key do not match" });
|
||||
}
|
||||
|
||||
const certDest = path.join(SSL_DIR, "termix.crt");
|
||||
const keyDest = path.join(SSL_DIR, "termix.key");
|
||||
await fs.rename(tmpCertFile, certDest);
|
||||
await fs.rename(tmpKeyFile, keyDest);
|
||||
await fs.chmod(keyDest, 0o600);
|
||||
await fs.chmod(certDest, 0o644);
|
||||
} finally {
|
||||
await fs.rm(tmpCertFile, { force: true });
|
||||
await fs.rm(tmpKeyFile, { force: true });
|
||||
}
|
||||
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
const existing = await settingsRepository.get("acme_ssl_settings");
|
||||
const current = existing ? JSON.parse(existing) : {};
|
||||
const updated = {
|
||||
...current,
|
||||
challengeType: "manual",
|
||||
lastIssuedAt: new Date().toISOString(),
|
||||
};
|
||||
await settingsRepository.set(
|
||||
"acme_ssl_settings",
|
||||
JSON.stringify(updated),
|
||||
);
|
||||
|
||||
authLogger.info("Manual SSL certificate installed", {
|
||||
operation: "manual_ssl_installed",
|
||||
});
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "manual_ssl_upload",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ success: true }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ success: true, ...(await getAcmeSettings()) });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
authLogger.error("Manual SSL certificate upload failed", err);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor?.username ?? userId,
|
||||
action: "manual_ssl_upload",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ error: message }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: false,
|
||||
});
|
||||
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Certificate installation failed: ${message}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1483,7 +1483,7 @@ router.get(
|
||||
* name: field
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [password, sudoPassword, vncPassword]
|
||||
* enum: [password, sudoPassword, vncPassword, key, keyPassword]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The requested password value.
|
||||
@@ -1499,7 +1499,11 @@ router.get(
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const field = (req.query.field as string) || "password";
|
||||
|
||||
if (!["password", "sudoPassword", "vncPassword"].includes(field)) {
|
||||
if (
|
||||
!["password", "sudoPassword", "vncPassword", "key", "keyPassword"].includes(
|
||||
field,
|
||||
)
|
||||
) {
|
||||
return res.status(400).json({ error: "Invalid field" });
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
||||
hiddenRailTabs: row?.hiddenRailTabs ?? null,
|
||||
compactHostView: row?.compactHostView ?? null,
|
||||
statusColorScheme: row?.statusColorScheme ?? null,
|
||||
customThemes: row?.customThemes ?? null,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -106,6 +107,10 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
||||
* statusColorScheme:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* customThemes:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: JSON-encoded array of the user's saved global custom terminal themes.
|
||||
*/
|
||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -175,6 +180,9 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* type: boolean
|
||||
* statusColorScheme:
|
||||
* type: string
|
||||
* customThemes:
|
||||
* type: string
|
||||
* description: JSON-encoded array of the user's saved global custom terminal themes.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Preferences updated successfully.
|
||||
@@ -201,6 +209,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
hiddenRailTabs,
|
||||
compactHostView,
|
||||
statusColorScheme,
|
||||
customThemes,
|
||||
} = req.body as {
|
||||
reopenTabsOnLogin?: boolean;
|
||||
theme?: string | null;
|
||||
@@ -221,6 +230,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
hiddenRailTabs?: string | null;
|
||||
compactHostView?: boolean | null;
|
||||
statusColorScheme?: string | null;
|
||||
customThemes?: string | null;
|
||||
};
|
||||
|
||||
const updates: UserPreferenceUpdate = {
|
||||
@@ -244,12 +254,41 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
storageMode,
|
||||
hiddenRailTabs,
|
||||
statusColorScheme,
|
||||
customThemes,
|
||||
})) {
|
||||
if (value !== undefined && value !== null && typeof value !== "string") {
|
||||
return res.status(400).json({ error: `${key} must be a string` });
|
||||
}
|
||||
}
|
||||
|
||||
if (customThemes !== undefined && customThemes !== null) {
|
||||
let parsedThemes: unknown;
|
||||
try {
|
||||
parsedThemes = JSON.parse(customThemes);
|
||||
} catch {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "customThemes must be a JSON-encoded array" });
|
||||
}
|
||||
if (!Array.isArray(parsedThemes) || parsedThemes.length > 100) {
|
||||
return res.status(400).json({
|
||||
error: "customThemes must be a JSON array of at most 100 themes",
|
||||
});
|
||||
}
|
||||
const isValidTheme = (entry: unknown): boolean =>
|
||||
!!entry &&
|
||||
typeof entry === "object" &&
|
||||
typeof (entry as { id?: unknown }).id === "string" &&
|
||||
typeof (entry as { name?: unknown }).name === "string" &&
|
||||
!!(entry as { colors?: unknown }).colors &&
|
||||
typeof (entry as { colors?: unknown }).colors === "object";
|
||||
if (!parsedThemes.every(isValidTheme)) {
|
||||
return res.status(400).json({
|
||||
error: "Each custom theme must have an id, name, and colors object",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const boolFields: Record<string, boolean | null | undefined> = {
|
||||
commandAutocomplete,
|
||||
commandPaletteEnabled,
|
||||
@@ -294,6 +333,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
if (compactHostView !== undefined) updates.compactHostView = compactHostView;
|
||||
if (statusColorScheme !== undefined)
|
||||
updates.statusColorScheme = statusColorScheme;
|
||||
if (customThemes !== undefined) updates.customThemes = customThemes;
|
||||
|
||||
if (Object.keys(updates).length === 1) {
|
||||
return res.status(400).json({ error: "No preferences provided" });
|
||||
|
||||
@@ -519,6 +519,103 @@ export function registerUserSettingsRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/analytics-enabled:
|
||||
* get:
|
||||
* summary: Get analytics enabled setting
|
||||
* description: Returns whether anonymous usage telemetry is enabled.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Analytics enabled status.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
*/
|
||||
router.get("/analytics-enabled", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
enabled: await createCurrentSettingsRepository().getBoolean(
|
||||
"analytics_enabled",
|
||||
true,
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get analytics enabled setting", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to get analytics enabled setting" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/analytics-enabled:
|
||||
* patch:
|
||||
* summary: Update analytics enabled setting (admin only)
|
||||
* description: Enables or disables the daily anonymous usage telemetry heartbeat.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Setting updated.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to update setting.
|
||||
*/
|
||||
router.patch("/analytics-enabled", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const actor = await getAdminActor(userId);
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
const { enabled } = req.body;
|
||||
if (typeof enabled !== "boolean") {
|
||||
return res.status(400).json({ error: "enabled must be a boolean" });
|
||||
}
|
||||
await createCurrentSettingsRepository().set(
|
||||
"analytics_enabled",
|
||||
enabled ? "true" : "false",
|
||||
);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "update_analytics_enabled",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ enabled }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ enabled });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update analytics enabled setting", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to update analytics enabled setting" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/host-defaults:
|
||||
|
||||
@@ -166,6 +166,12 @@ router.post("/token", async (req, res) => {
|
||||
* type: string
|
||||
* enum: [rdp, vnc, telnet]
|
||||
* description: Override the host's default connection type
|
||||
* promptedUsername:
|
||||
* type: string
|
||||
* description: Username for this connection only, used when the host's RDP auth type is "none". Not persisted.
|
||||
* promptedPassword:
|
||||
* type: string
|
||||
* description: Password for this connection only, used when the host's RDP auth type is "none". Not persisted.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Connection token generated successfully
|
||||
@@ -422,12 +428,22 @@ router.post(
|
||||
let username: string;
|
||||
let password: string;
|
||||
|
||||
const rdpAuthTypeForConnect = isSharedConnection
|
||||
? null
|
||||
: (host.rdpAuthType as string) ||
|
||||
(host.rdpCredentialId ? "credential" : "direct");
|
||||
|
||||
switch (connectionType) {
|
||||
case "rdp":
|
||||
username =
|
||||
(host.rdpUser as string) || (host.username as string) || "";
|
||||
password =
|
||||
(host.rdpPassword as string) || (host.password as string) || "";
|
||||
if (rdpAuthTypeForConnect === "none") {
|
||||
username = String(req.body?.promptedUsername || "");
|
||||
password = String(req.body?.promptedPassword || "");
|
||||
} else {
|
||||
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":
|
||||
|
||||
@@ -170,6 +170,9 @@ import {
|
||||
});
|
||||
}
|
||||
|
||||
const { startAnalyticsHeartbeat } = await import("./utils/analytics.js");
|
||||
startAnalyticsHeartbeat();
|
||||
|
||||
systemLogger.success("Termix backend started successfully", {
|
||||
operation: "backend_init_complete",
|
||||
port: process.env.PORT || 4090,
|
||||
|
||||
@@ -47,6 +47,7 @@ describe("UserPreferenceRepository", () => {
|
||||
hidden_rail_tabs TEXT,
|
||||
compact_host_view INTEGER,
|
||||
status_color_scheme TEXT,
|
||||
custom_themes TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const mockGetBoolean = vi.fn();
|
||||
const mockGet = vi.fn();
|
||||
const mockSet = vi.fn();
|
||||
const mockPost = vi.fn();
|
||||
|
||||
function makeChain(resolveValue: unknown) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
const methods = ["from", "where", "groupBy"];
|
||||
for (const m of methods) {
|
||||
chain[m] = vi.fn(() => chain);
|
||||
}
|
||||
(chain as unknown as Promise<unknown>).then = (
|
||||
cb: (v: unknown) => unknown,
|
||||
) => Promise.resolve(resolveValue).then(cb);
|
||||
return chain;
|
||||
}
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
getBoolean: mockGetBoolean,
|
||||
get: mockGet,
|
||||
set: mockSet,
|
||||
}),
|
||||
createCurrentRepositoryContext: () => ({
|
||||
drizzle: {
|
||||
select: vi.fn(() => makeChain([{ count: 0 }])),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/schema.js", () => ({
|
||||
users: {},
|
||||
hosts: {},
|
||||
recentActivity: { type: "type", timestamp: "timestamp" },
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
Logger: class {
|
||||
info = vi.fn();
|
||||
warn = vi.fn();
|
||||
error = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("axios", () => ({
|
||||
default: { post: mockPost },
|
||||
}));
|
||||
|
||||
describe("analytics", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("isAnalyticsEnabled defaults to true via the settings repository", async () => {
|
||||
mockGetBoolean.mockResolvedValue(true);
|
||||
const { isAnalyticsEnabled } = await import("../../utils/analytics.js");
|
||||
|
||||
const result = await isAnalyticsEnabled();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockGetBoolean).toHaveBeenCalledWith("analytics_enabled", true);
|
||||
});
|
||||
|
||||
it("getOrCreateInstanceId returns the existing id without generating one", async () => {
|
||||
mockGet.mockResolvedValue("existing-id");
|
||||
const { getOrCreateInstanceId } = await import(
|
||||
"../../utils/analytics.js"
|
||||
);
|
||||
|
||||
const id = await getOrCreateInstanceId();
|
||||
|
||||
expect(id).toBe("existing-id");
|
||||
expect(mockSet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("getOrCreateInstanceId generates and persists a new id when absent", async () => {
|
||||
mockGet.mockResolvedValue(null);
|
||||
const { getOrCreateInstanceId } = await import(
|
||||
"../../utils/analytics.js"
|
||||
);
|
||||
|
||||
const id = await getOrCreateInstanceId();
|
||||
|
||||
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(mockSet).toHaveBeenCalledWith("analytics_instance_id", id);
|
||||
});
|
||||
|
||||
it("collectAndSendHeartbeat does not call PostHog when POSTHOG_API_KEY is unset", async () => {
|
||||
delete process.env.POSTHOG_API_KEY;
|
||||
const { collectAndSendHeartbeat } = await import(
|
||||
"../../utils/analytics.js"
|
||||
);
|
||||
|
||||
await collectAndSendHeartbeat();
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("collectAndSendHeartbeat does not call PostHog when analytics is disabled", async () => {
|
||||
process.env.POSTHOG_API_KEY = "phc_test";
|
||||
mockGetBoolean.mockResolvedValue(false);
|
||||
const { collectAndSendHeartbeat } = await import(
|
||||
"../../utils/analytics.js"
|
||||
);
|
||||
|
||||
await collectAndSendHeartbeat();
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("collectAndSendHeartbeat posts a heartbeat event with the expected shape when enabled", async () => {
|
||||
process.env.POSTHOG_API_KEY = "phc_test";
|
||||
mockGetBoolean.mockResolvedValue(true);
|
||||
mockGet.mockResolvedValue("instance-123");
|
||||
mockPost.mockResolvedValue({});
|
||||
const { collectAndSendHeartbeat } = await import(
|
||||
"../../utils/analytics.js"
|
||||
);
|
||||
|
||||
await collectAndSendHeartbeat();
|
||||
|
||||
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||
const [url, body] = mockPost.mock.calls[0];
|
||||
expect(url).toContain("/capture/");
|
||||
expect(body).toMatchObject({
|
||||
api_key: "phc_test",
|
||||
event: "instance_heartbeat",
|
||||
distinct_id: "instance-123",
|
||||
properties: expect.objectContaining({
|
||||
user_count: 0,
|
||||
host_count: 0,
|
||||
used_terminal: 0,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import crypto from "crypto";
|
||||
import axios from "axios";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { users, hosts, recentActivity } from "../database/db/schema.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentRepositoryContext,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { Logger } from "./logger.js";
|
||||
|
||||
export const analyticsLogger = new Logger("ANALYTICS", "📈", "#06b6d4");
|
||||
|
||||
const FEATURE_ACTIVITY_TYPES = [
|
||||
"terminal",
|
||||
"file_manager",
|
||||
"tunnel",
|
||||
"docker",
|
||||
"telnet",
|
||||
"vnc",
|
||||
"rdp",
|
||||
"server_stats",
|
||||
] as const;
|
||||
|
||||
const POSTHOG_HOST = process.env.POSTHOG_HOST || "https://us.i.posthog.com";
|
||||
const HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export async function isAnalyticsEnabled(): Promise<boolean> {
|
||||
return createCurrentSettingsRepository().getBoolean("analytics_enabled", true);
|
||||
}
|
||||
|
||||
export async function getOrCreateInstanceId(): Promise<string> {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const existing = await settings.get("analytics_instance_id");
|
||||
if (existing) return existing;
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
await settings.set("analytics_instance_id", id);
|
||||
return id;
|
||||
}
|
||||
|
||||
function getAppVersion(): string {
|
||||
return process.env.VERSION || "unknown";
|
||||
}
|
||||
|
||||
async function collectFeatureUsage(): Promise<Record<string, number>> {
|
||||
const since = new Date(Date.now() - HEARTBEAT_INTERVAL_MS).toISOString();
|
||||
const db = createCurrentRepositoryContext().drizzle;
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
type: recentActivity.type,
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(recentActivity)
|
||||
.where(sql`${recentActivity.timestamp} >= ${since}`)
|
||||
.groupBy(recentActivity.type);
|
||||
|
||||
const counts = new Map(rows.map((row) => [row.type, Number(row.count)]));
|
||||
const usage: Record<string, number> = {};
|
||||
for (const type of FEATURE_ACTIVITY_TYPES) {
|
||||
usage[`used_${type}`] = counts.get(type) ?? 0;
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
async function collectCounts(): Promise<{
|
||||
userCount: number;
|
||||
hostCount: number;
|
||||
}> {
|
||||
const db = createCurrentRepositoryContext().drizzle;
|
||||
|
||||
const [userRows, hostRows] = await Promise.all([
|
||||
db.select({ count: sql<number>`count(*)` }).from(users),
|
||||
db.select({ count: sql<number>`count(*)` }).from(hosts),
|
||||
]);
|
||||
|
||||
return {
|
||||
userCount: Number(userRows[0]?.count ?? 0),
|
||||
hostCount: Number(hostRows[0]?.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectAndSendHeartbeat(): Promise<void> {
|
||||
const apiKey = process.env.POSTHOG_API_KEY;
|
||||
if (!apiKey) return;
|
||||
|
||||
try {
|
||||
if (!(await isAnalyticsEnabled())) return;
|
||||
|
||||
const instanceId = await getOrCreateInstanceId();
|
||||
const { userCount, hostCount } = await collectCounts();
|
||||
const featureUsage = await collectFeatureUsage();
|
||||
|
||||
await axios.post(
|
||||
`${POSTHOG_HOST}/capture/`,
|
||||
{
|
||||
api_key: apiKey,
|
||||
event: "instance_heartbeat",
|
||||
distinct_id: instanceId,
|
||||
properties: {
|
||||
version: getAppVersion(),
|
||||
user_count: userCount,
|
||||
host_count: hostCount,
|
||||
...featureUsage,
|
||||
},
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
analyticsLogger.info("Sent daily usage heartbeat", {
|
||||
operation: "analytics_heartbeat_sent",
|
||||
});
|
||||
} catch (err) {
|
||||
analyticsLogger.warn("Failed to send usage heartbeat", {
|
||||
operation: "analytics_heartbeat_failed",
|
||||
error: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function startAnalyticsHeartbeat(): void {
|
||||
if (!process.env.POSTHOG_API_KEY) {
|
||||
analyticsLogger.info(
|
||||
"Analytics disabled: POSTHOG_API_KEY not set",
|
||||
{ operation: "analytics_disabled_no_key" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void collectAndSendHeartbeat();
|
||||
setInterval(() => void collectAndSendHeartbeat(), HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
+2
-2
@@ -207,7 +207,7 @@ export interface Host {
|
||||
telnetUser?: string;
|
||||
telnetPassword?: string;
|
||||
telnetCredentialId?: number | null;
|
||||
rdpAuthType?: "direct" | "credential" | null;
|
||||
rdpAuthType?: "direct" | "credential" | "none" | null;
|
||||
vncAuthType?: "direct" | "credential" | null;
|
||||
telnetAuthType?: "direct" | "credential" | null;
|
||||
createdAt: string;
|
||||
@@ -329,7 +329,7 @@ export interface HostData {
|
||||
telnetUser?: string;
|
||||
telnetPassword?: string;
|
||||
telnetCredentialId?: number | null;
|
||||
rdpAuthType?: "direct" | "credential" | null;
|
||||
rdpAuthType?: "direct" | "credential" | "none" | null;
|
||||
vncAuthType?: "direct" | "credential" | null;
|
||||
telnetAuthType?: "direct" | "credential" | null;
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ export type Host = {
|
||||
vncPort: number;
|
||||
telnetPort: number;
|
||||
|
||||
rdpAuthType?: "direct" | "credential";
|
||||
rdpAuthType?: "direct" | "credential" | "none";
|
||||
rdpCredentialId?: string;
|
||||
rdpUser?: string;
|
||||
rdpPassword?: string;
|
||||
|
||||
+48
-6
@@ -222,11 +222,14 @@ export function AppShell({
|
||||
const [splitMode, setSplitMode] = useState<SplitMode>(
|
||||
() => (localStorage.getItem("termix_splitMode") as SplitMode) ?? "none",
|
||||
);
|
||||
const [paneTabIds, setPaneTabIds] = useState<(string | null)[]>(
|
||||
() =>
|
||||
JSON.parse(localStorage.getItem("termix_paneTabIds") ?? "null") ??
|
||||
Array(6).fill(null),
|
||||
// paneTabIds holds live tab.id values, which change on every restore, so we
|
||||
// can't restore it from storage directly. It starts empty and gets filled in
|
||||
// once by the reconciliation effect below, keyed off the stable instanceId
|
||||
// values saved in termix_paneInstanceIds.
|
||||
const [paneTabIds, setPaneTabIds] = useState<(string | null)[]>(() =>
|
||||
Array(6).fill(null),
|
||||
);
|
||||
const paneLayoutRestoredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
paneTabIdsRef.current = paneTabIds;
|
||||
}, [paneTabIds]);
|
||||
@@ -262,8 +265,18 @@ export function AppShell({
|
||||
}, [splitMode]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("termix_paneTabIds", JSON.stringify(paneTabIds));
|
||||
}, [paneTabIds]);
|
||||
// Don't overwrite the saved layout with the empty initial state before
|
||||
// reconciliation has had a chance to restore it.
|
||||
if (!paneLayoutRestoredRef.current) return;
|
||||
const instanceIds = paneTabIds.map((id) => {
|
||||
if (id == null) return null;
|
||||
return tabs.find((t) => t.id === id)?.instanceId ?? null;
|
||||
});
|
||||
localStorage.setItem(
|
||||
"termix_paneInstanceIds",
|
||||
JSON.stringify(instanceIds),
|
||||
);
|
||||
}, [paneTabIds, tabs]);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
@@ -976,6 +989,35 @@ export function AppShell({
|
||||
loadSavedTabs();
|
||||
}, [hostsLoaded, userPrefsLoaded]);
|
||||
|
||||
// Restore split-screen pane assignments once tabs are settled. Saved assignments are
|
||||
// keyed by instanceId (stable across reloads) and remapped to the live tab.id here,
|
||||
// since tab.id is regenerated every time a tab is (re)opened.
|
||||
useEffect(() => {
|
||||
if (!tabsReady || paneLayoutRestoredRef.current) return;
|
||||
paneLayoutRestoredRef.current = true;
|
||||
|
||||
try {
|
||||
const savedInstanceIds: (string | null)[] = JSON.parse(
|
||||
localStorage.getItem("termix_paneInstanceIds") ?? "null",
|
||||
);
|
||||
if (!Array.isArray(savedInstanceIds)) return;
|
||||
|
||||
const restored = savedInstanceIds.map((instanceId) => {
|
||||
if (instanceId == null) return null;
|
||||
return tabs.find((t) => t.instanceId === instanceId)?.id ?? null;
|
||||
});
|
||||
if (restored.some((id) => id != null)) {
|
||||
setPaneTabIds(restored);
|
||||
} else {
|
||||
// None of the saved panes could be restored (e.g. reopen-tabs-on-login
|
||||
// is disabled), so drop back to a single view instead of an empty split.
|
||||
setSplitMode("none");
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}, [tabsReady, tabs]);
|
||||
|
||||
// Debounced tab-order sync: when tab order changes, patch each persistent tab's tabOrder in DB.
|
||||
const orderSyncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export type AcmeChallengeType = "http-webroot" | "dns-cloudflare";
|
||||
export type AcmeChallengeType = "http-webroot" | "dns-cloudflare" | "manual";
|
||||
|
||||
export type AcmeSettings = {
|
||||
enabled: boolean;
|
||||
@@ -45,3 +45,15 @@ export async function requestAcmeCertificate(): Promise<
|
||||
handleApiError(error, "request ACME certificate");
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadManualSslCertificate(payload: {
|
||||
certificate: string;
|
||||
privateKey: string;
|
||||
}): Promise<AcmeSettings & { success: boolean }> {
|
||||
try {
|
||||
const response = await authApi.post("/users/manual-ssl-upload", payload);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "upload manual SSL certificate");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,12 @@ export async function adminDeleteUserHost(
|
||||
export async function adminGetHostPassword(
|
||||
targetUserId: string,
|
||||
hostId: number,
|
||||
field: "password" | "sudoPassword" | "vncPassword" = "password",
|
||||
field:
|
||||
| "password"
|
||||
| "sudoPassword"
|
||||
| "vncPassword"
|
||||
| "key"
|
||||
| "keyPassword" = "password",
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await sshHostApi.get(
|
||||
|
||||
@@ -96,7 +96,12 @@ export async function getSSHHostWithCredentials(
|
||||
|
||||
export async function getHostPassword(
|
||||
hostId: number,
|
||||
field: "password" | "sudoPassword" | "vncPassword" = "password",
|
||||
field:
|
||||
| "password"
|
||||
| "sudoPassword"
|
||||
| "vncPassword"
|
||||
| "key"
|
||||
| "keyPassword" = "password",
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await sshHostApi.get(
|
||||
|
||||
@@ -208,12 +208,18 @@ export async function getGuacamoleToken(
|
||||
export async function getGuacamoleTokenFromHost(
|
||||
hostId: number,
|
||||
protocol?: "rdp" | "vnc" | "telnet",
|
||||
promptedCredentials?: { username?: string; password?: string },
|
||||
): Promise<GuacamoleTokenResponse> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
`/guacamole/connect-host/${hostId}`,
|
||||
protocol ? { protocol } : {},
|
||||
);
|
||||
const response = await authApi.post(`/guacamole/connect-host/${hostId}`, {
|
||||
...(protocol ? { protocol } : {}),
|
||||
...(promptedCredentials?.username
|
||||
? { promptedUsername: promptedCredentials.username }
|
||||
: {}),
|
||||
...(promptedCredentials?.password
|
||||
? { promptedPassword: promptedCredentials.password }
|
||||
: {}),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get guacamole token from host");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { authApi } from "@/main-axios";
|
||||
import { createTtlRequestCache } from "@/lib/ttl-request-cache";
|
||||
import type { TerminalTheme } from "@/lib/terminal-themes";
|
||||
|
||||
// OPEN TABS API
|
||||
// ============================================================================
|
||||
@@ -82,6 +83,12 @@ export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
|
||||
// USER PREFERENCES API
|
||||
// ============================================================================
|
||||
|
||||
export interface SavedCustomTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
colors: TerminalTheme["colors"];
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
reopenTabsOnLogin: boolean;
|
||||
theme?: string | null;
|
||||
@@ -102,6 +109,17 @@ export interface UserPreferences {
|
||||
hiddenRailTabs?: string | null;
|
||||
compactHostView?: boolean | null;
|
||||
statusColorScheme?: string | null;
|
||||
customThemes?: string | null;
|
||||
}
|
||||
|
||||
export function parseCustomThemes(raw?: string | null): SavedCustomTheme[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserPreferences(): Promise<UserPreferences> {
|
||||
|
||||
@@ -145,6 +145,32 @@ export async function updateGuacamoleSettings(settings: {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ANALYTICS SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
export async function getAnalyticsEnabled(): Promise<{ enabled: boolean }> {
|
||||
try {
|
||||
const response = await authApi.get("/users/analytics-enabled");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch analytics enabled setting");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAnalyticsEnabled(
|
||||
enabled: boolean,
|
||||
): Promise<{ enabled: boolean }> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/analytics-enabled", {
|
||||
enabled,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update analytics enabled setting");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HOST DEFAULTS SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
@@ -20,6 +20,15 @@ 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 { Input } from "@/components/input.tsx";
|
||||
import { PasswordInput } from "@/components/password-input.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog.tsx";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import type { SSHHost } from "@/types";
|
||||
|
||||
@@ -97,7 +106,10 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
|
||||
|
||||
interface GuacamoleAppInnerProps {
|
||||
hostId: number;
|
||||
hostConfig: Pick<SSHHost, "connectionType" | "guacamoleConfig">;
|
||||
hostConfig: Pick<
|
||||
SSHHost,
|
||||
"connectionType" | "guacamoleConfig" | "rdpAuthType"
|
||||
>;
|
||||
hostName: string;
|
||||
tabId?: string;
|
||||
protocol?: "rdp" | "vnc" | "telnet";
|
||||
@@ -123,12 +135,31 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
);
|
||||
const displayRef = useRef<GuacamoleDisplayHandle>(null);
|
||||
|
||||
const resolvedProtocolForConnect = (protocol ??
|
||||
hostConfig.connectionType ??
|
||||
"rdp") as "rdp" | "vnc" | "telnet";
|
||||
const needsCredentialPrompt =
|
||||
resolvedProtocolForConnect === "rdp" && hostConfig.rdpAuthType === "none";
|
||||
|
||||
const [promptedCredentials, setPromptedCredentials] = useState<{
|
||||
username: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
const [promptOpen, setPromptOpen] = useState(needsCredentialPrompt);
|
||||
const [promptUsername, setPromptUsername] = useState("");
|
||||
const [promptPassword, setPromptPassword] = useState("");
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
disconnect: () => displayRef.current?.disconnect(),
|
||||
isConnected: () => displayRef.current?.isConnected() === true,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (needsCredentialPrompt && !promptedCredentials) {
|
||||
setPromptOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setToken(null);
|
||||
setError(null);
|
||||
getGuacdStatus()
|
||||
@@ -137,26 +168,44 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
setError(t("guacamole.guacdUnavailable"));
|
||||
return;
|
||||
}
|
||||
return getGuacamoleTokenFromHost(hostId, protocol);
|
||||
return getGuacamoleTokenFromHost(
|
||||
hostId,
|
||||
protocol,
|
||||
promptedCredentials ?? undefined,
|
||||
);
|
||||
})
|
||||
.then((result) => {
|
||||
if (result) {
|
||||
setToken(result.token);
|
||||
const resolvedProtocol = (protocol ??
|
||||
hostConfig.connectionType ??
|
||||
"rdp") as "rdp" | "vnc" | "telnet";
|
||||
logActivity(resolvedProtocol, hostId, hostName).catch(() => {});
|
||||
logActivity(resolvedProtocolForConnect, hostId, hostName).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => setError(err?.message || t("guacamole.failedToConnect")));
|
||||
}, [hostConfig.connectionType, hostId, hostName, protocol, retryCount, t]);
|
||||
}, [
|
||||
hostId,
|
||||
hostName,
|
||||
protocol,
|
||||
retryCount,
|
||||
t,
|
||||
needsCredentialPrompt,
|
||||
promptedCredentials,
|
||||
resolvedProtocolForConnect,
|
||||
]);
|
||||
|
||||
const handleReconnect = useCallback(() => {
|
||||
setConnectionError(null);
|
||||
setError(null);
|
||||
setToken(null);
|
||||
if (needsCredentialPrompt) {
|
||||
setPromptedCredentials(null);
|
||||
setPromptUsername("");
|
||||
setPromptPassword("");
|
||||
setPromptOpen(true);
|
||||
}
|
||||
setRetryCount((c) => c + 1);
|
||||
}, []);
|
||||
}, [needsCredentialPrompt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tabId) return;
|
||||
@@ -169,6 +218,67 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
window.removeEventListener("termix:refresh-guacamole", handler);
|
||||
}, [tabId, handleReconnect]);
|
||||
|
||||
if (promptOpen) {
|
||||
return (
|
||||
<Dialog
|
||||
open={promptOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPromptOpen(false);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("guacamole.credentialPromptTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("guacamole.credentialPromptDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4 mt-1"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setPromptedCredentials({
|
||||
username: promptUsername,
|
||||
password: promptPassword,
|
||||
});
|
||||
setPromptOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.guac.username")}
|
||||
</label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Administrator"
|
||||
value={promptUsername}
|
||||
onChange={(e) => setPromptUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.guac.password")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={promptPassword}
|
||||
onChange={(e) => setPromptPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-2">
|
||||
<Button type="submit" variant="outline">
|
||||
{t("guacamole.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
@@ -216,10 +326,7 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedProtocol = (protocol ?? hostConfig.connectionType) as
|
||||
| "rdp"
|
||||
| "vnc"
|
||||
| "telnet";
|
||||
const resolvedProtocol = resolvedProtocolForConnect;
|
||||
const configuredDpi = Number(hostConfig.guacamoleConfig?.dpi);
|
||||
|
||||
return (
|
||||
|
||||
+5
-5
@@ -112,7 +112,7 @@
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.2115 0.0042 128.68);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--muted-foreground: oklch(0.44 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.2115 0.0042 128.68);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
@@ -479,19 +479,19 @@
|
||||
|
||||
/* Font size scale applied to html element */
|
||||
html.fs-xs {
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
html.fs-sm {
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
html.fs-md {
|
||||
font-size: 14px;
|
||||
}
|
||||
html.fs-lg {
|
||||
font-size: 16px;
|
||||
font-size: 17px;
|
||||
}
|
||||
html.fs-xl {
|
||||
font-size: 18px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
@supports (padding-bottom: env(safe-area-inset-bottom)) {
|
||||
|
||||
@@ -763,6 +763,14 @@
|
||||
"customThemeBrightCyan": "Bright Cyan",
|
||||
"customThemeBrightWhite": "Bright White",
|
||||
"customThemeResetTooltip": "Reset to defaults",
|
||||
"savedThemesLabel": "Saved Themes",
|
||||
"saveAsGlobalTheme": "Save as Global Theme",
|
||||
"saveGlobalThemeNamePrompt": "Enter a name for this theme",
|
||||
"saveGlobalThemeSuccess": "Theme saved",
|
||||
"saveGlobalThemeError": "Failed to save theme",
|
||||
"applyGlobalThemeTooltip": "Apply this theme",
|
||||
"deleteGlobalThemeTooltip": "Delete this theme",
|
||||
"noSavedThemes": "No saved themes yet",
|
||||
"syntaxHighlightingLabel": "Syntax Highlighting",
|
||||
"syntaxHighlightingDesc": "Colorize terminal output (errors, paths, IPs, timestamps)",
|
||||
"syntaxHighlightingCategories": "Highlight Categories",
|
||||
@@ -963,6 +971,9 @@
|
||||
"failedToSaveCredential": "Failed to save credential",
|
||||
"credentialNameRequired": "Please enter a name for the credential",
|
||||
"credentialAuthRequired": "Add a password, an SSH key, or both",
|
||||
"createCredentialFromHostBtn": "Create Credential",
|
||||
"createCredentialFromHostTitle": "Create Credential From Host",
|
||||
"createCredentialFromHostDesc": "Create a reusable, shareable credential entry prefilled with this host's current username, password, and/or SSH key.",
|
||||
"backToHosts": "Back to Hosts",
|
||||
"backToCredentials": "Back to Credentials",
|
||||
"pinned": "Pinned",
|
||||
@@ -1252,6 +1263,8 @@
|
||||
"authMethod": "Auth Method",
|
||||
"authTypeDirect": "Direct",
|
||||
"authTypeCredential": "Credential",
|
||||
"authTypeNone": "None",
|
||||
"authTypeNoneDesc": "No credentials are stored. You'll be prompted for a username and password each time you connect; they are not saved.",
|
||||
"selectCredential": "Select a credential...",
|
||||
"connectionSettings": "Connection Settings",
|
||||
"displaySettings": "Display Settings",
|
||||
@@ -1446,6 +1459,9 @@
|
||||
"reconnect": "Reconnect",
|
||||
"retry": "Retry",
|
||||
"guacdUnavailable": "Remote desktop service (guacd) is not available. Please ensure guacd is running and accessible and configured properly in admin settings.",
|
||||
"credentialPromptTitle": "Enter RDP Credentials",
|
||||
"credentialPromptDescription": "This host is set to prompt for credentials on connect. They are used for this session only and are not saved.",
|
||||
"connect": "Connect",
|
||||
"ctrlAltDel": "Ctrl+Alt+Del",
|
||||
"toolbar": {
|
||||
"ctrlAltDel": "Ctrl+Alt+Del",
|
||||
@@ -2656,6 +2672,17 @@
|
||||
"sslSaveFailed": "Failed to save SSL settings",
|
||||
"sslRequiresDomain": "Domain and email are required",
|
||||
"sslInfoNote": "After issuing a certificate, enable SSL in your environment variables (ENABLE_SSL=true) and restart Termix.",
|
||||
"sslManualOption": "Manual (upload certificate)",
|
||||
"sslManualCert": "Certificate (PEM)",
|
||||
"sslManualCertPlaceholder": "-----BEGIN CERTIFICATE-----",
|
||||
"sslManualKey": "Private Key (PEM)",
|
||||
"sslManualKeyPlaceholder": "-----BEGIN PRIVATE KEY-----",
|
||||
"sslManualDesc": "Paste your existing certificate and private key, including a full chain if required by your CA.",
|
||||
"sslManualUpload": "Upload & Install Certificate",
|
||||
"sslManualUploadLoading": "Uploading certificate...",
|
||||
"sslManualUploadSuccess": "Certificate uploaded and installed successfully",
|
||||
"sslManualUploadFailed": "Certificate upload failed",
|
||||
"sslManualRequiresFields": "Certificate and private key are required",
|
||||
"auditLogTotal": "{{total}} total entries",
|
||||
"auditLogEmpty": "No audit log entries found",
|
||||
"auditLogSuccess": "Success",
|
||||
@@ -2684,6 +2711,9 @@
|
||||
"commandHistoryEnabled": "Command History",
|
||||
"commandHistoryEnabledDesc": "Allow command history recording. When disabled, history is not saved regardless of per-host settings.",
|
||||
"updateCommandHistoryFailed": "Failed to update command history setting",
|
||||
"analyticsEnabled": "Share Anonymous Usage Statistics",
|
||||
"analyticsEnabledDesc": "Sends an anonymous daily count of users, hosts, and feature usage to help improve Termix. No personal data or connection details are ever included.",
|
||||
"updateAnalyticsFailed": "Failed to update analytics setting",
|
||||
"sessionTimeout": "Session Timeout",
|
||||
"hours": "hours",
|
||||
"sessionTimeoutRange": "Min 1h · Max 720h",
|
||||
@@ -3146,6 +3176,7 @@
|
||||
"sectionAppearance": "Appearance",
|
||||
"sectionSecurity": "Security",
|
||||
"sectionApiKeys": "API Keys",
|
||||
"sectionData": "Data",
|
||||
"sectionC2sTunnels": "C2S Tunnels",
|
||||
"usernameLabel": "Username",
|
||||
"roleLabel": "Role",
|
||||
@@ -3240,6 +3271,22 @@
|
||||
"apiKeyUsageHint": "Include your key in the",
|
||||
"apiKeyUsageHintHeader": "header.",
|
||||
"apiKeyPermissionsHint": "Keys inherit the permissions of the creating user.",
|
||||
"exportData": "Export My Data",
|
||||
"exportDataDesc": "Download a backup of your hosts, credentials, and settings to transfer to another device",
|
||||
"export": "Export",
|
||||
"exporting": "Exporting...",
|
||||
"importData": "Import My Data",
|
||||
"importDataDesc": "Restore your hosts, credentials, and settings from a .sqlite backup file",
|
||||
"importDataSelected": "Selected: {{name}}",
|
||||
"selectFile": "Select File",
|
||||
"changeFile": "Change",
|
||||
"import": "Import",
|
||||
"importing": "Importing...",
|
||||
"exportSuccess": "Data exported successfully",
|
||||
"exportFailed": "Data export failed",
|
||||
"importSelectFile": "Please select a file first",
|
||||
"importCompleted": "Import completed: {{total}} items imported, {{skipped}} skipped",
|
||||
"importFailed": "Import failed: {{error}}",
|
||||
"roleUser": "User",
|
||||
"authMethodDual": "Dual Auth",
|
||||
"authMethodOidc": "OIDC",
|
||||
|
||||
@@ -140,12 +140,15 @@ export function CommandPalette({
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [isOpen, setIsOpen]);
|
||||
|
||||
const filteredHosts = hosts.filter(
|
||||
(h) =>
|
||||
h.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
h.ip.toLowerCase().includes(search.toLowerCase()) ||
|
||||
h.username.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
const filteredHosts = hosts.filter((h) => {
|
||||
const query = search.toLowerCase();
|
||||
return (
|
||||
h.name.toLowerCase().includes(query) ||
|
||||
h.ip.toLowerCase().includes(query) ||
|
||||
h.username.toLowerCase().includes(query) ||
|
||||
h.tags?.some((tag) => tag.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
|
||||
// Group hosts by folder; ungrouped hosts appear first under an implicit root group
|
||||
const groupedHosts: { folder: string | null; hosts: Host[] }[] = [];
|
||||
|
||||
@@ -41,12 +41,15 @@ import {
|
||||
updateTailscaleSettings,
|
||||
getHostDefaults,
|
||||
updateHostDefaults,
|
||||
getAnalyticsEnabled,
|
||||
updateAnalyticsEnabled,
|
||||
type HostDefaults,
|
||||
} from "@/api/settings-api";
|
||||
import {
|
||||
getAcmeSslSettings,
|
||||
updateAcmeSslSettings,
|
||||
requestAcmeCertificate,
|
||||
uploadManualSslCertificate,
|
||||
type AcmeSettings,
|
||||
} from "@/api/acme-ssl-api";
|
||||
import {
|
||||
@@ -126,6 +129,7 @@ export function AdminSettingsPanel({
|
||||
const [logLevel, setLogLevel] = useState("info");
|
||||
const [tailscaleApiKey, setTailscaleApiKey] = useState("");
|
||||
const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true);
|
||||
const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
|
||||
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
|
||||
|
||||
// SSO / auto-provision state
|
||||
@@ -200,6 +204,9 @@ export function AdminSettingsPanel({
|
||||
useState<AcmeSettings>(defaultAcmeSettings);
|
||||
const [cloudflareTokenDraft, setCloudflareTokenDraft] = useState("");
|
||||
const [acmeRequesting, setAcmeRequesting] = useState(false);
|
||||
const [manualCertDraft, setManualCertDraft] = useState("");
|
||||
const [manualKeyDraft, setManualKeyDraft] = useState("");
|
||||
const [manualUploading, setManualUploading] = useState(false);
|
||||
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [sessions, setSessions] = useState<AdminSession[]>([]);
|
||||
@@ -281,6 +288,7 @@ export function AdminSettingsPanel({
|
||||
oidcSilent,
|
||||
tailscale,
|
||||
cmdHistory,
|
||||
analytics,
|
||||
] = await Promise.allSettled([
|
||||
getRegistrationAllowed(),
|
||||
getPasswordLoginAllowed(),
|
||||
@@ -293,6 +301,7 @@ export function AdminSettingsPanel({
|
||||
getOidcSilentLoginDefault(),
|
||||
getTailscaleSettings(),
|
||||
getCommandHistoryEnabled(),
|
||||
getAnalyticsEnabled(),
|
||||
]);
|
||||
|
||||
if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed);
|
||||
@@ -324,6 +333,9 @@ export function AdminSettingsPanel({
|
||||
if (cmdHistory.status === "fulfilled") {
|
||||
setCommandHistoryEnabled(cmdHistory.value.enabled);
|
||||
}
|
||||
if (analytics.status === "fulfilled") {
|
||||
setAnalyticsEnabled(analytics.value.enabled);
|
||||
}
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
@@ -431,6 +443,17 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleAnalytics() {
|
||||
const newVal = !analyticsEnabled;
|
||||
setAnalyticsEnabled(newVal);
|
||||
try {
|
||||
await updateAnalyticsEnabled(newVal);
|
||||
} catch {
|
||||
setAnalyticsEnabled(!newVal);
|
||||
toast.error(t("admin.updateAnalyticsFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveSessionTimeout() {
|
||||
const hours = parseInt(sessionTimeout, 10);
|
||||
if (isNaN(hours) || hours < 1 || hours > 720) {
|
||||
@@ -592,6 +615,28 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleManualSslUpload() {
|
||||
if (!manualCertDraft.trim() || !manualKeyDraft.trim()) {
|
||||
toast.error(t("admin.sslManualRequiresFields"));
|
||||
return;
|
||||
}
|
||||
setManualUploading(true);
|
||||
try {
|
||||
const result = await uploadManualSslCertificate({
|
||||
certificate: manualCertDraft,
|
||||
privateKey: manualKeyDraft,
|
||||
});
|
||||
setAcmeSettings(result);
|
||||
setManualCertDraft("");
|
||||
setManualKeyDraft("");
|
||||
toast.success(t("admin.sslManualUploadSuccess"));
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("admin.sslManualUploadFailed")));
|
||||
} finally {
|
||||
setManualUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleProviderSaved(saved: SSOProvider) {
|
||||
setSsoProviders((prev) => {
|
||||
const idx = prev.findIndex((p) => p.id === saved.id);
|
||||
@@ -861,6 +906,8 @@ export function AdminSettingsPanel({
|
||||
<AdminGeneralSettingsSection
|
||||
open={openSections.has("general")}
|
||||
onToggle={() => toggle("general")}
|
||||
analyticsEnabled={analyticsEnabled}
|
||||
handleToggleAnalytics={handleToggleAnalytics}
|
||||
allowRegistration={allowRegistration}
|
||||
handleToggleRegistration={handleToggleRegistration}
|
||||
allowPasswordLogin={allowPasswordLogin}
|
||||
@@ -982,6 +1029,12 @@ export function AdminSettingsPanel({
|
||||
requesting={acmeRequesting}
|
||||
handleSave={handleSaveAcmeSettings}
|
||||
handleRequest={handleRequestAcmeCertificate}
|
||||
manualCertDraft={manualCertDraft}
|
||||
setManualCertDraft={setManualCertDraft}
|
||||
manualKeyDraft={manualKeyDraft}
|
||||
setManualKeyDraft={setManualKeyDraft}
|
||||
manualUploading={manualUploading}
|
||||
handleManualUpload={handleManualSslUpload}
|
||||
/>
|
||||
|
||||
<AdminApiKeysSection
|
||||
|
||||
@@ -30,6 +30,8 @@ import type { AcmeSettings, AcmeChallengeType } from "@/api/acme-ssl-api";
|
||||
type GeneralSettingsSectionProps = {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
analyticsEnabled: boolean;
|
||||
handleToggleAnalytics: () => void;
|
||||
allowRegistration: boolean;
|
||||
handleToggleRegistration: () => void;
|
||||
allowPasswordLogin: boolean;
|
||||
@@ -67,6 +69,8 @@ type GeneralSettingsSectionProps = {
|
||||
export function AdminGeneralSettingsSection({
|
||||
open,
|
||||
onToggle,
|
||||
analyticsEnabled,
|
||||
handleToggleAnalytics,
|
||||
allowRegistration,
|
||||
handleToggleRegistration,
|
||||
allowPasswordLogin,
|
||||
@@ -110,6 +114,12 @@ export function AdminGeneralSettingsSection({
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="flex flex-col gap-0 pt-2">
|
||||
<SettingRow
|
||||
label={t("admin.analyticsEnabled")}
|
||||
description={t("admin.analyticsEnabledDesc")}
|
||||
>
|
||||
<AdminToggle on={analyticsEnabled} onToggle={handleToggleAnalytics} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("admin.allowRegistration")}
|
||||
description={t("admin.allowRegistrationDesc")}
|
||||
@@ -1038,6 +1048,12 @@ type AdminSSLSectionProps = {
|
||||
requesting: boolean;
|
||||
handleSave: () => void;
|
||||
handleRequest: () => void;
|
||||
manualCertDraft: string;
|
||||
setManualCertDraft: Dispatch<SetStateAction<string>>;
|
||||
manualKeyDraft: string;
|
||||
setManualKeyDraft: Dispatch<SetStateAction<string>>;
|
||||
manualUploading: boolean;
|
||||
handleManualUpload: () => void;
|
||||
};
|
||||
|
||||
export function AdminSSLSection({
|
||||
@@ -1050,6 +1066,12 @@ export function AdminSSLSection({
|
||||
requesting,
|
||||
handleSave,
|
||||
handleRequest,
|
||||
manualCertDraft,
|
||||
setManualCertDraft,
|
||||
manualKeyDraft,
|
||||
setManualKeyDraft,
|
||||
manualUploading,
|
||||
handleManualUpload,
|
||||
}: AdminSSLSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -1107,34 +1129,6 @@ export function AdminSSLSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslDomain")}
|
||||
</label>
|
||||
<Input
|
||||
value={settings.domain}
|
||||
onChange={(e) =>
|
||||
setSettings((p) => ({ ...p, domain: e.target.value }))
|
||||
}
|
||||
placeholder={t("admin.sslDomainPlaceholder")}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslEmail")}
|
||||
</label>
|
||||
<Input
|
||||
value={settings.email}
|
||||
onChange={(e) =>
|
||||
setSettings((p) => ({ ...p, email: e.target.value }))
|
||||
}
|
||||
placeholder={t("admin.sslEmailPlaceholder")}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslChallengeType")}
|
||||
@@ -1158,6 +1152,9 @@ export function AdminSSLSection({
|
||||
<SelectItem value="dns-cloudflare" className="text-xs">
|
||||
DNS (Cloudflare)
|
||||
</SelectItem>
|
||||
<SelectItem value="manual" className="text-xs">
|
||||
{t("admin.sslManualOption")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
@@ -1165,6 +1162,38 @@ export function AdminSSLSection({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{settings.challengeType !== "manual" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslDomain")}
|
||||
</label>
|
||||
<Input
|
||||
value={settings.domain}
|
||||
onChange={(e) =>
|
||||
setSettings((p) => ({ ...p, domain: e.target.value }))
|
||||
}
|
||||
placeholder={t("admin.sslDomainPlaceholder")}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslEmail")}
|
||||
</label>
|
||||
<Input
|
||||
value={settings.email}
|
||||
onChange={(e) =>
|
||||
setSettings((p) => ({ ...p, email: e.target.value }))
|
||||
}
|
||||
placeholder={t("admin.sslEmailPlaceholder")}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{settings.challengeType === "dns-cloudflare" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
@@ -1185,34 +1214,86 @@ export function AdminSSLSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings.challengeType === "manual" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslManualCert")}
|
||||
</label>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={manualCertDraft}
|
||||
onChange={(e) => setManualCertDraft(e.target.value)}
|
||||
placeholder={t("admin.sslManualCertPlaceholder")}
|
||||
spellCheck={false}
|
||||
className="w-full px-2 py-1.5 text-[10px] font-mono bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslManualKey")}
|
||||
</label>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={manualKeyDraft}
|
||||
onChange={(e) => setManualKeyDraft(e.target.value)}
|
||||
placeholder={t("admin.sslManualKeyPlaceholder")}
|
||||
spellCheck={false}
|
||||
className="w-full px-2 py-1.5 text-[10px] font-mono bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.sslManualDesc")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
|
||||
onClick={handleManualUpload}
|
||||
disabled={manualUploading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3 ${manualUploading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{manualUploading
|
||||
? t("admin.sslManualUploadLoading")
|
||||
: t("admin.sslManualUpload")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className="text-[10px] text-muted-foreground border-t border-border pt-2">
|
||||
{t("admin.sslInfoNote")}
|
||||
</span>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t("admin.sslSave")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
|
||||
onClick={handleRequest}
|
||||
disabled={requesting}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3 ${requesting ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{requesting
|
||||
? t("admin.sslRequestCertLoading")
|
||||
: t("admin.sslRequestCert")}
|
||||
</Button>
|
||||
</div>
|
||||
{settings.challengeType !== "manual" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t("admin.sslSave")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
|
||||
onClick={handleRequest}
|
||||
disabled={requesting}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3 ${requesting ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{requesting
|
||||
? t("admin.sslRequestCertLoading")
|
||||
: t("admin.sslRequestCert")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
adminUpdateUserCredential,
|
||||
} from "@/main-axios";
|
||||
import type { Credential } from "@/types/ui-types";
|
||||
import { FolderPathPicker } from "./FolderPathPicker";
|
||||
|
||||
type CredentialWithCertificate = Credential & { certPublicKey?: string };
|
||||
|
||||
@@ -151,19 +152,11 @@ export function CredentialEditorView({
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.folder")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Server Keys"
|
||||
<FolderPathPicker
|
||||
value={credForm.folder}
|
||||
onChange={(e) => setCredField("folder", e.target.value)}
|
||||
list="cred-folder-suggestions"
|
||||
onChange={(path) => setCredField("folder", path)}
|
||||
folderPaths={existingFolders}
|
||||
/>
|
||||
{existingFolders.length > 0 && (
|
||||
<datalist id="cred-folder-suggestions">
|
||||
{existingFolders.map((f) => (
|
||||
<option key={f} value={f} />
|
||||
))}
|
||||
</datalist>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
|
||||
@@ -43,8 +43,16 @@ import {
|
||||
adminUpdateUserHost,
|
||||
adminGetHostPassword,
|
||||
adminGetUserSnippets,
|
||||
createCredential,
|
||||
adminCreateUserCredential,
|
||||
} from "@/main-axios";
|
||||
import { getTailscaleDevices, getHostDefaults } from "@/api/settings-api";
|
||||
import {
|
||||
getUserPreferences,
|
||||
saveUserPreferences,
|
||||
parseCustomThemes,
|
||||
type SavedCustomTheme,
|
||||
} from "@/api/open-tabs-api";
|
||||
import type { Host, VaultProfile } from "@/types/ui-types";
|
||||
import type { SSHHost, TunnelStatus } from "@/types";
|
||||
import { useTabsSafe } from "@/shell/TabContext";
|
||||
@@ -131,6 +139,66 @@ export function HostEditor({
|
||||
const [isOidcUser, setIsOidcUser] = useState(false);
|
||||
const [vaultProfiles, setVaultProfiles] = useState<VaultProfile[]>([]);
|
||||
const [showVaultManager, setShowVaultManager] = useState(false);
|
||||
const [quickCredentialName, setQuickCredentialName] = useState("");
|
||||
const [creatingQuickCredential, setCreatingQuickCredential] =
|
||||
useState(false);
|
||||
const [showQuickCredentialDialog, setShowQuickCredentialDialog] =
|
||||
useState(false);
|
||||
const [savedThemes, setSavedThemes] = useState<SavedCustomTheme[]>([]);
|
||||
const [savingTheme, setSavingTheme] = useState(false);
|
||||
|
||||
const reloadSavedThemes = () => {
|
||||
getUserPreferences()
|
||||
.then((prefs) => setSavedThemes(parseCustomThemes(prefs.customThemes)))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reloadSavedThemes();
|
||||
}, []);
|
||||
|
||||
const handleSaveAsGlobalTheme = async () => {
|
||||
const colors = form.customThemeColors;
|
||||
if (!colors) return;
|
||||
const name = window.prompt(t("hosts.saveGlobalThemeNamePrompt"));
|
||||
if (!name || !name.trim()) return;
|
||||
setSavingTheme(true);
|
||||
try {
|
||||
const newTheme: SavedCustomTheme = {
|
||||
id: `theme-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: name.trim(),
|
||||
colors,
|
||||
};
|
||||
const updated = [...savedThemes, newTheme];
|
||||
await saveUserPreferences({
|
||||
customThemes: JSON.stringify(updated),
|
||||
});
|
||||
setSavedThemes(updated);
|
||||
toast.success(t("hosts.saveGlobalThemeSuccess"));
|
||||
} catch {
|
||||
toast.error(t("hosts.saveGlobalThemeError"));
|
||||
} finally {
|
||||
setSavingTheme(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteGlobalTheme = async (id: string) => {
|
||||
const updated = savedThemes.filter((theme) => theme.id !== id);
|
||||
try {
|
||||
await saveUserPreferences({
|
||||
customThemes: JSON.stringify(updated),
|
||||
});
|
||||
setSavedThemes(updated);
|
||||
} catch {
|
||||
toast.error(t("hosts.saveGlobalThemeError"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyGlobalTheme = (id: string) => {
|
||||
const theme = savedThemes.find((entry) => entry.id === id);
|
||||
if (!theme) return;
|
||||
setField("customThemeColors", { ...theme.colors });
|
||||
};
|
||||
|
||||
const reloadVaultProfiles = () => {
|
||||
getVaultProfiles()
|
||||
@@ -227,6 +295,77 @@ export function HostEditor({
|
||||
(c) => c.id === form.credentialId,
|
||||
);
|
||||
|
||||
const canQuickCreateCredential =
|
||||
(authMethod === "password" || authMethod === "key") &&
|
||||
(authMethod === "password"
|
||||
? !!form.password || !!host?.hasPassword
|
||||
: (!!form.key && form.key !== "existing_key") || !!host?.hasKey);
|
||||
|
||||
const openQuickCredentialDialog = () => {
|
||||
setQuickCredentialName(form.name || form.username || "");
|
||||
setShowQuickCredentialDialog(true);
|
||||
};
|
||||
|
||||
const handleQuickCreateCredential = async () => {
|
||||
if (!quickCredentialName.trim()) {
|
||||
toast.error(t("hosts.credentialNameRequired"));
|
||||
return;
|
||||
}
|
||||
setCreatingQuickCredential(true);
|
||||
try {
|
||||
const fetchField = (field: "password" | "key" | "keyPassword") =>
|
||||
adminTargetUserId
|
||||
? adminGetHostPassword(adminTargetUserId, Number(host?.id), field)
|
||||
: getHostPassword(Number(host?.id), field);
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
name: quickCredentialName,
|
||||
username: form.username || null,
|
||||
folder: form.folder || null,
|
||||
};
|
||||
|
||||
if (authMethod === "password") {
|
||||
data.authType = "password";
|
||||
data.password =
|
||||
form.password ||
|
||||
(host?.hasPassword ? await fetchField("password") : null);
|
||||
} else {
|
||||
const key =
|
||||
form.key && form.key !== "existing_key"
|
||||
? form.key
|
||||
: host?.hasKey
|
||||
? await fetchField("key")
|
||||
: null;
|
||||
const keyPassword =
|
||||
form.keyPassword && form.keyPassword !== "existing_key_password"
|
||||
? form.keyPassword
|
||||
: host?.hasKeyPassword
|
||||
? await fetchField("keyPassword")
|
||||
: null;
|
||||
data.authType = "key";
|
||||
data.key = key;
|
||||
data.keyPassword = keyPassword;
|
||||
data.password = form.password || null;
|
||||
}
|
||||
|
||||
if (adminTargetUserId) {
|
||||
await adminCreateUserCredential(adminTargetUserId, data);
|
||||
} else {
|
||||
await createCredential(data);
|
||||
}
|
||||
toast.success(t("hosts.credentialCreated"));
|
||||
if (!adminTargetUserId) {
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
}
|
||||
setShowQuickCredentialDialog(false);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : null;
|
||||
toast.error(msg || t("hosts.failedToSaveCredential"));
|
||||
} finally {
|
||||
setCreatingQuickCredential(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Shared hosts: view-level recipients see a read-only editor; edit-level
|
||||
// recipients may change the host but never its credential/vault references
|
||||
// or auth type (owner-only, enforced server-side too).
|
||||
@@ -321,6 +460,20 @@ export function HostEditor({
|
||||
<SectionCard
|
||||
title={t("hosts.authenticationLabel")}
|
||||
icon={<Shield className="size-3.5" />}
|
||||
action={
|
||||
canQuickCreateCredential && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
|
||||
onClick={openQuickCredentialDialog}
|
||||
>
|
||||
<Plus className="size-3 mr-1" />
|
||||
{t("hosts.createCredentialFromHostBtn")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -1060,6 +1213,66 @@ export function HostEditor({
|
||||
</div>
|
||||
{form.theme === "custom" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.savedThemesLabel")}
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px]"
|
||||
disabled={savingTheme || !form.customThemeColors}
|
||||
onClick={handleSaveAsGlobalTheme}
|
||||
>
|
||||
{t("hosts.saveAsGlobalTheme")}
|
||||
</Button>
|
||||
</div>
|
||||
{savedThemes.length === 0 ? (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.noSavedThemes")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{savedThemes.map((theme) => (
|
||||
<div
|
||||
key={theme.id}
|
||||
className="flex items-center justify-between gap-2 border border-border px-2 py-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title={t("hosts.applyGlobalThemeTooltip")}
|
||||
onClick={() =>
|
||||
handleApplyGlobalTheme(theme.id)
|
||||
}
|
||||
className="flex items-center gap-2 text-xs text-left flex-1 min-w-0 hover:text-foreground transition-colors"
|
||||
>
|
||||
<span
|
||||
className="size-3.5 shrink-0 border border-border"
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{theme.name}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t("hosts.deleteGlobalThemeTooltip")}
|
||||
onClick={() =>
|
||||
handleDeleteGlobalTheme(theme.id)
|
||||
}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.customThemeColors")}
|
||||
@@ -2112,6 +2325,58 @@ export function HostEditor({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showQuickCredentialDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
||||
<div className="bg-popover border border-border shadow-xl w-full max-w-sm flex flex-col gap-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-bold">
|
||||
{t("hosts.createCredentialFromHostTitle")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowQuickCredentialDialog(false)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hosts.createCredentialFromHostDesc")}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.friendlyNameLabel")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Production SSH Key"
|
||||
value={quickCredentialName}
|
||||
onChange={(e) => setQuickCredentialName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowQuickCredentialDialog(false)}
|
||||
disabled={creatingQuickCredential}
|
||||
>
|
||||
{t("hosts.cancelBtn")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleQuickCreateCredential}
|
||||
disabled={creatingQuickCredential}
|
||||
>
|
||||
{creatingQuickCredential
|
||||
? t("hosts.savingBtn")
|
||||
: t("hosts.addCredentialBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -205,7 +205,8 @@ export function createHostEditorForm(
|
||||
rdpAuthType: (host?.rdpAuthType ??
|
||||
(host?.rdpCredentialId ? "credential" : "direct")) as
|
||||
| "direct"
|
||||
| "credential",
|
||||
| "credential"
|
||||
| "none",
|
||||
vncAuthType: (host?.vncAuthType ??
|
||||
(host?.vncCredentialId ? "credential" : "direct")) as
|
||||
| "direct"
|
||||
|
||||
@@ -174,88 +174,106 @@ export function HostEditorRdpTab({
|
||||
icon={<Shield className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
{credentials && credentials.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.authMethod")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(["direct", "credential"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setField("rdpAuthType", m)}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${
|
||||
form.rdpAuthType === m
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.authMethod")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(
|
||||
[
|
||||
"direct",
|
||||
...(credentials && credentials.length > 0
|
||||
? (["credential"] as const)
|
||||
: []),
|
||||
"none",
|
||||
] as const
|
||||
).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setField("rdpAuthType", m)}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${
|
||||
form.rdpAuthType === m
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t(
|
||||
`hosts.guac.authType${m.charAt(0).toUpperCase() + m.slice(1)}`,
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{form.rdpAuthType === "none" ? (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.guac.authTypeNoneDesc")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{form.rdpAuthType === "credential" &&
|
||||
credentials &&
|
||||
credentials.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5 col-span-full">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.storedCredential")}
|
||||
</label>
|
||||
<select
|
||||
value={form.rdpCredentialId}
|
||||
onChange={(e) =>
|
||||
setField("rdpCredentialId", e.target.value)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{t(
|
||||
`hosts.guac.authType${m.charAt(0).toUpperCase() + m.slice(1)}`,
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<option value="">
|
||||
{t("hosts.guac.selectCredential")}
|
||||
</option>
|
||||
{credentials.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.username ? `${c.name} (${c.username})` : c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.username")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Administrator"
|
||||
value={form.rdpUser}
|
||||
onChange={(e) => setField("rdpUser", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.password")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={form.rdpPassword}
|
||||
onChange={(e) =>
|
||||
setField("rdpPassword", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.domain")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="WORKGROUP"
|
||||
value={form.domain}
|
||||
onChange={(e) => setField("domain", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{form.rdpAuthType === "credential" &&
|
||||
credentials &&
|
||||
credentials.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5 col-span-full">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.storedCredential")}
|
||||
</label>
|
||||
<select
|
||||
value={form.rdpCredentialId}
|
||||
onChange={(e) => setField("rdpCredentialId", e.target.value)}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t("hosts.guac.selectCredential")}</option>
|
||||
{credentials.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.username ? `${c.name} (${c.username})` : c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.username")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Administrator"
|
||||
value={form.rdpUser}
|
||||
onChange={(e) => setField("rdpUser", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.password")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={form.rdpPassword}
|
||||
onChange={(e) => setField("rdpPassword", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.domain")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="WORKGROUP"
|
||||
value={form.domain}
|
||||
onChange={(e) => setField("domain", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
|
||||
@@ -515,12 +515,12 @@ export function HostItem({
|
||||
</button>
|
||||
)}
|
||||
{!selectionMode && !shouldUseClickTray && (
|
||||
<span className="text-[11px] text-muted-foreground/45 truncate leading-none ml-auto shrink-0 group-hover:hidden">
|
||||
<span className="text-[11px] text-muted-foreground/70 truncate leading-none ml-auto shrink-0 group-hover:hidden">
|
||||
{host.ip}
|
||||
</span>
|
||||
)}
|
||||
{selectionMode && (
|
||||
<span className="text-[11px] text-muted-foreground/45 truncate leading-none ml-auto shrink-0">
|
||||
<span className="text-[11px] text-muted-foreground/70 truncate leading-none ml-auto shrink-0">
|
||||
{host.ip}
|
||||
</span>
|
||||
)}
|
||||
@@ -529,7 +529,7 @@ export function HostItem({
|
||||
{/* Click-tray mode: always-visible action buttons */}
|
||||
{shouldUseClickTray && !selectionMode && (
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-150 ease-out ${isTrayOpen || isMenuOpen ? "max-h-[200px] opacity-100" : "max-h-0 opacity-0"}`}
|
||||
className={`overflow-hidden transition-all duration-150 ease-out ${isTrayOpen || isMenuOpen ? "max-h-[72px] opacity-100" : "max-h-0 opacity-0"}`}
|
||||
>
|
||||
<div className="flex items-center flex-wrap gap-1 px-2 pb-1">
|
||||
{getSshActions(host).map(({ type, icon: Icon, label }) => (
|
||||
@@ -730,7 +730,7 @@ export function HostItem({
|
||||
|
||||
{/* Hover tray (non-click-tray mode) */}
|
||||
{!shouldUseClickTray && !selectionMode && (
|
||||
<div className="max-h-0 opacity-0 overflow-hidden transition-all duration-150 ease-out group-hover:max-h-[200px] group-hover:opacity-100">
|
||||
<div className="max-h-0 opacity-0 overflow-hidden transition-all duration-150 ease-out group-hover:max-h-[72px] group-hover:opacity-100">
|
||||
<div className="flex items-center flex-wrap gap-1 px-2 pb-1">
|
||||
{getSshActions(host).map(({ type, icon: Icon, label }) => (
|
||||
<button
|
||||
@@ -1054,7 +1054,7 @@ export function HostItem({
|
||||
</div>
|
||||
|
||||
{/* Address — always visible */}
|
||||
<span className="text-[11px] text-muted-foreground/45 truncate leading-none pl-3">
|
||||
<span className="text-[11px] text-muted-foreground/70 truncate leading-none pl-3">
|
||||
{host.username}@{host.ip}
|
||||
</span>
|
||||
|
||||
@@ -1158,7 +1158,7 @@ export function HostItem({
|
||||
|
||||
{/* Action tray — slides open on hover (default) or via chevron in click-tray mode */}
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 ${!shouldUseClickTray ? "group-hover:max-h-[300px] group-hover:opacity-100" : ""} ${selectionMode ? "!max-h-0 !opacity-0" : ""} ${(isMenuOpen || (shouldUseClickTray && isTrayOpen)) && !selectionMode ? "!max-h-[300px] !opacity-100" : ""}`}
|
||||
className={`overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 ${!shouldUseClickTray ? "group-hover:max-h-[130px] group-hover:opacity-100" : ""} ${selectionMode ? "!max-h-0 !opacity-0" : ""} ${(isMenuOpen || (shouldUseClickTray && isTrayOpen)) && !selectionMode ? "!max-h-[130px] !opacity-100" : ""}`}
|
||||
>
|
||||
{isOnline &&
|
||||
((host.cpu != null && host.cpu > 0) ||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
@@ -29,6 +29,7 @@ import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { FolderPathPicker } from "./FolderPathPicker";
|
||||
import { Separator } from "@/components/separator";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -150,6 +151,14 @@ function SnippetFormDialog({
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const folderMeta = useMemo(() => {
|
||||
const map = new Map<string, { color?: string; icon?: string }>();
|
||||
for (const f of folders) {
|
||||
map.set(f.name, { color: f.color ?? undefined, icon: f.icon });
|
||||
}
|
||||
return map;
|
||||
}, [folders]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(snippet?.name ?? "");
|
||||
@@ -238,20 +247,12 @@ function SnippetFormDialog({
|
||||
({t("newUi.sidebar.snippets.optional")})
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
<FolderPathPicker
|
||||
value={folder ?? ""}
|
||||
onChange={(e) =>
|
||||
setFolder(e.target.value === "" ? null : e.target.value)
|
||||
}
|
||||
className="px-3 py-2 text-sm bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t("newUi.sidebar.snippets.noFolder")}</option>
|
||||
{folders.map((f) => (
|
||||
<option key={f.id} value={f.name}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(path) => setFolder(path === "" ? null : path)}
|
||||
folderPaths={folders.map((f) => f.name)}
|
||||
folderMeta={folderMeta}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
getUserRoles,
|
||||
saveUserPreferences,
|
||||
getUserPreferences,
|
||||
getConfiguredServerUrl,
|
||||
} from "@/main-axios";
|
||||
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
|
||||
import {
|
||||
deleteWebAuthnCredential,
|
||||
listWebAuthnCredentials,
|
||||
@@ -45,6 +47,7 @@ import {
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Copy,
|
||||
Database,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Fingerprint,
|
||||
@@ -86,6 +89,7 @@ type UserProfileSection =
|
||||
| "appearance"
|
||||
| "security"
|
||||
| "api-keys"
|
||||
| "data"
|
||||
| "c2s-tunnels";
|
||||
|
||||
const THEMES: { id: ThemeId; preview: string }[] = [
|
||||
@@ -502,6 +506,11 @@ export function UserProfilePanel({
|
||||
const [deletePassword, setDeletePassword] = useState("");
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
|
||||
// Data export/import
|
||||
const [importFile, setImportFile] = useState<File | null>(null);
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
|
||||
// UI state
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [newKeyOpen, setNewKeyOpen] = useState(false);
|
||||
@@ -1129,6 +1138,116 @@ export function UserProfilePanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportData() {
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const apiUrl = getDatabaseTransferUrl("export", {
|
||||
electron: isElectron(),
|
||||
configuredServerUrl: getConfiguredServerUrl(),
|
||||
location: window.location,
|
||||
});
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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("newUi.sidebar.userProfile.exportSuccess"));
|
||||
} else {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
toast.error(
|
||||
err.error || t("newUi.sidebar.userProfile.exportFailed"),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("newUi.sidebar.userProfile.exportFailed"));
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportData() {
|
||||
if (!importFile) {
|
||||
toast.error(t("newUi.sidebar.userProfile.importSelectFile"));
|
||||
return;
|
||||
}
|
||||
setImportLoading(true);
|
||||
try {
|
||||
const apiUrl = getDatabaseTransferUrl("import", {
|
||||
electron: isElectron(),
|
||||
configuredServerUrl: getConfiguredServerUrl(),
|
||||
location: window.location,
|
||||
});
|
||||
|
||||
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 s = result.summary;
|
||||
const total =
|
||||
(s.sshHostsImported || 0) +
|
||||
(s.sshCredentialsImported || 0) +
|
||||
(s.fileManagerItemsImported || 0) +
|
||||
(s.dismissedAlertsImported || 0) +
|
||||
(s.settingsImported || 0);
|
||||
toast.success(
|
||||
t("newUi.sidebar.userProfile.importCompleted", {
|
||||
total,
|
||||
skipped: s.skippedItems || 0,
|
||||
}),
|
||||
);
|
||||
setImportFile(null);
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
} else {
|
||||
toast.error(
|
||||
t("newUi.sidebar.userProfile.importFailed", {
|
||||
error: result.summary?.errors?.join(", ") || "Unknown error",
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
toast.error(
|
||||
t("newUi.sidebar.userProfile.importFailed", {
|
||||
error: err.error || "Unknown error",
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
t("newUi.sidebar.userProfile.importFailed", {
|
||||
error: "Unknown error",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canChangePasword = !isOidc || isDualAuth;
|
||||
|
||||
return (
|
||||
@@ -2310,6 +2429,80 @@ export function UserProfilePanel({
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection
|
||||
id="data"
|
||||
label={t("newUi.sidebar.userProfile.sectionData")}
|
||||
icon={<Database className="size-3.5" />}
|
||||
open={openSections.has("data")}
|
||||
onToggle={() => toggle("data")}
|
||||
>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium">
|
||||
{t("newUi.sidebar.userProfile.exportData")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.exportDataDesc")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand mt-1"
|
||||
onClick={handleExportData}
|
||||
disabled={exportLoading}
|
||||
>
|
||||
{exportLoading
|
||||
? t("newUi.sidebar.userProfile.exporting")
|
||||
: t("newUi.sidebar.userProfile.export")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 border-t border-border pt-3">
|
||||
<span className="text-xs font-medium">
|
||||
{t("newUi.sidebar.userProfile.importData")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{importFile
|
||||
? t("newUi.sidebar.userProfile.importDataSelected", {
|
||||
name: importFile.name,
|
||||
})
|
||||
: t("newUi.sidebar.userProfile.importDataDesc")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="relative">
|
||||
<input
|
||||
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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="pointer-events-none text-xs"
|
||||
>
|
||||
{importFile
|
||||
? t("newUi.sidebar.userProfile.changeFile")
|
||||
: t("newUi.sidebar.userProfile.selectFile")}
|
||||
</Button>
|
||||
</div>
|
||||
{importFile && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleImportData}
|
||||
disabled={importLoading}
|
||||
>
|
||||
{importLoading
|
||||
? t("newUi.sidebar.userProfile.importing")
|
||||
: t("newUi.sidebar.userProfile.import")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
{isElectron() && (
|
||||
<AccordionSection
|
||||
id="c2s-tunnels"
|
||||
|
||||
Reference in New Issue
Block a user