fix: general qol additions and new analytics/telemetrics feature

This commit is contained in:
LukeGus
2026-07-20 13:29:03 -05:00
parent 00c0fe7cab
commit c1c06272d7
38 changed files with 1701 additions and 606 deletions
+1
View File
@@ -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 (
+1
View File
@@ -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`),
+156 -2
View File
@@ -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}` });
}
});
}
+6 -2
View File
@@ -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:
+20 -4
View File
@@ -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":
+3
View File
@@ -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
);
+145
View File
@@ -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,
}),
});
});
});
+132
View File
@@ -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
View File
@@ -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;
}
+1 -1
View File
@@ -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
View File
@@ -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,
+13 -1
View File
@@ -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");
}
}
+6 -1
View File
@@ -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(
+6 -1
View File
@@ -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(
+10 -4
View File
@@ -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");
+18
View File
@@ -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> {
+26
View File
@@ -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
// ============================================================================
+119 -12
View File
@@ -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
View File
@@ -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)) {
+47
View File
@@ -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",
+9 -6
View File
@@ -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[] }[] = [];
+53
View File
@@ -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
+133 -52
View File
@@ -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>
);
+4 -11
View File
@@ -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">
+265
View File
@@ -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>
);
}
+2 -1
View File
@@ -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"
+96 -78
View File
@@ -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>
+6 -6
View File
@@ -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) ||
+15 -14
View File
@@ -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">
+193
View File
@@ -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"