From 7ba45969f6a8ab9b3474fe9c0cbb5454303c4278 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 20 Jul 2026 01:28:17 -0500 Subject: [PATCH] fix: general qol additions --- src/backend/database/db/index.ts | 17 +- src/backend/database/db/schema.ts | 3 + .../repositories/host-folder-repository.ts | 10 +- .../host-resolution-repository.ts | 30 +- .../database/routes/host-folder-routes.ts | 35 +- src/backend/database/routes/host.ts | 105 +++++- src/backend/database/routes/rbac.ts | 220 +++++++++++++ src/backend/hosts/host-resolver.ts | 80 +++-- src/backend/hosts/metrics/index.ts | 18 + .../host-folder-repository.test.ts | 25 ++ .../host-resolution-repository.test.ts | 36 ++ src/backend/tests/hosts/host-resolver.test.ts | 60 ++++ src/types/index.ts | 1 + src/types/ui-types.ts | 1 + src/ui/AppShell.tsx | 12 +- src/ui/api/credentials-api.ts | 3 + src/ui/api/rbac-api.ts | 25 ++ src/ui/locales/en.json | 8 + src/ui/main-axios.ts | 1 + src/ui/sidebar/AdminSettingsPanel.tsx | 31 +- src/ui/sidebar/FolderMetadataDialog.tsx | 59 +++- src/ui/sidebar/HostShareModal.tsx | 307 ++++++++++-------- src/ui/sidebar/SidebarTree.tsx | 53 ++- src/ui/sidebar/UserProfilePanel.tsx | 31 +- 24 files changed, 963 insertions(+), 208 deletions(-) diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 586280cb..b751654d 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -390,9 +390,11 @@ async function initializeCompleteDatabase(): Promise { name TEXT NOT NULL, color TEXT, icon TEXT, + credential_id INTEGER, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS recent_activity ( @@ -1378,6 +1380,19 @@ const migrateSchema = () => { } } + try { + sqlite.prepare("SELECT credential_id FROM ssh_folders LIMIT 1").get(); + } catch { + try { + sqlite.exec("ALTER TABLE ssh_folders ADD COLUMN credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL"); + } catch (alterError) { + databaseLogger.warn("Failed to add credential_id column to ssh_folders", { + operation: "schema_migration", + error: alterError, + }); + } + } + try { sqlite.prepare("SELECT sudo_password FROM ssh_data LIMIT 1").get(); } catch { diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 6e0a3050..a3d99871 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -462,6 +462,9 @@ export const sshFolders = sqliteTable("ssh_folders", { name: text("name").notNull(), color: text("color"), icon: text("icon"), + credentialId: integer("credential_id").references(() => sshCredentials.id, { + onDelete: "set null", + }), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), diff --git a/src/backend/database/repositories/host-folder-repository.ts b/src/backend/database/repositories/host-folder-repository.ts index 50d0b169..287a5679 100644 --- a/src/backend/database/repositories/host-folder-repository.ts +++ b/src/backend/database/repositories/host-folder-repository.ts @@ -72,13 +72,20 @@ export class HostFolderRepository { name: string, color: string | null | undefined, icon: string | null | undefined, + credentialId?: number | null, now = new Date().toISOString(), ): Promise<{ folder: HostFolderRecord; created: boolean }> { const existing = await this.findFolder(userId, name); if (existing) { const [updated] = await this.context.drizzle .update(sshFolders) - .set({ color, icon, updatedAt: now }) + .set({ + color, + icon, + credentialId: + credentialId === undefined ? existing.credentialId : credentialId, + updatedAt: now, + }) .where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name))) .returning(); @@ -93,6 +100,7 @@ export class HostFolderRepository { name, color, icon, + credentialId: credentialId ?? null, createdAt: now, updatedAt: now, }) diff --git a/src/backend/database/repositories/host-resolution-repository.ts b/src/backend/database/repositories/host-resolution-repository.ts index 61288d58..31926f0b 100644 --- a/src/backend/database/repositories/host-resolution-repository.ts +++ b/src/backend/database/repositories/host-resolution-repository.ts @@ -1,5 +1,5 @@ import { and, eq, inArray, isNotNull } from "drizzle-orm"; -import { hostAccess, hosts, sshCredentials } from "../db/schema.js"; +import { hostAccess, hosts, sshCredentials, sshFolders } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; import { DataCrypto } from "../../utils/data-crypto.js"; @@ -315,6 +315,34 @@ export class HostResolutionRepository { return rows[0]?.overrideCredentialId ?? null; } + /** + * Resolve the nearest assigned credential for a folder path, walking up + * through parent folders (e.g. "Switches / Floor1" falls back to + * "Switches" if the child folder has no credential of its own). + */ + async findFolderCredentialId( + userId: string, + folderPath: string, + ): Promise { + const segments = folderPath.split(" / ").filter(Boolean); + if (segments.length === 0) return null; + + const paths = segments.map((_, i) => segments.slice(0, i + 1).join(" / ")); + const rows = await this.context.drizzle + .select({ name: sshFolders.name, credentialId: sshFolders.credentialId }) + .from(sshFolders) + .where( + and(eq(sshFolders.userId, userId), inArray(sshFolders.name, paths)), + ); + + const byName = new Map(rows.map((row) => [row.name, row.credentialId])); + for (let i = paths.length - 1; i >= 0; i--) { + const credentialId = byName.get(paths[i]); + if (credentialId) return credentialId; + } + return null; + } + private decryptOne>( tableName: "ssh_data" | "ssh_credentials", record: T | undefined, diff --git a/src/backend/database/routes/host-folder-routes.ts b/src/backend/database/routes/host-folder-routes.ts index dfde1491..d2225f62 100644 --- a/src/backend/database/routes/host-folder-routes.ts +++ b/src/backend/database/routes/host-folder-routes.ts @@ -3,6 +3,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import { databaseLogger, sshLogger } from "../../utils/logger.js"; import { createCurrentCommandHistoryRepository, + createCurrentCredentialRepository, createCurrentFileManagerBookmarkRepository, createCurrentHostFolderRepository, createCurrentRecentActivityRepository, @@ -138,7 +139,7 @@ export function registerHostFolderRoutes( * /host/folders/metadata: * put: * summary: Update folder metadata - * description: Updates the metadata (color, icon) of a folder. + * description: Updates the metadata (color, icon, assigned credential) of a folder. * tags: * - SSH * requestBody: @@ -154,6 +155,9 @@ export function registerHostFolderRoutes( * type: string * icon: * type: string + * credentialId: + * type: integer + * nullable: true * responses: * 200: * description: Folder metadata updated successfully. @@ -167,19 +171,46 @@ export function registerHostFolderRoutes( authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; - const { name, color, icon } = req.body; + const { name, color, icon, credentialId } = req.body; if (!isNonEmptyString(userId) || !name) { return res.status(400).json({ error: "Folder name is required" }); } + const normalizedCredentialId = + credentialId === undefined + ? undefined + : credentialId === null || credentialId === "" + ? null + : Number(credentialId); + + if ( + normalizedCredentialId !== undefined && + normalizedCredentialId !== null && + !Number.isInteger(normalizedCredentialId) + ) { + return res.status(400).json({ error: "Invalid credential ID" }); + } + try { + if (normalizedCredentialId) { + const credential = + await createCurrentCredentialRepository().findByIdForUser( + userId, + normalizedCredentialId, + ); + if (!credential) { + return res.status(404).json({ error: "Credential not found" }); + } + } + const { folder, created } = await createCurrentHostFolderRepository().upsertMetadata( userId, name, color, icon, + normalizedCredentialId, ); if (!created) { diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index e4a80ddd..767e11be 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -14,6 +14,7 @@ import { } from "../../hosts/credential-username.js"; import { createCurrentCommandHistoryRepository, + createCurrentCredentialRepository, createCurrentFileManagerBookmarkRepository, createCurrentOpksshTokenRepository, createCurrentRecentActivityRepository, @@ -1726,9 +1727,16 @@ router.get( * /host/db/hosts/export: * get: * summary: Export all SSH hosts - * description: Exports all SSH hosts for the current user with decrypted credentials. + * description: Exports all SSH hosts for the current user. By default credentials are decrypted and embedded. With `share=1`, secrets are omitted and credential-authenticated hosts instead reference a scrubbed `credentials` array by alias, suitable for handing off to another user. * tags: * - SSH + * parameters: + * - in: query + * name: share + * required: false + * schema: + * type: string + * description: Set to "1" to export without embedded secrets. * responses: * 200: * description: All exported SSH hosts. @@ -1743,6 +1751,7 @@ router.get( requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; + const shareMode = req.query.share === "1" || req.query.share === "true"; if (!isNonEmptyString(userId)) { return res.status(400).json({ error: "Invalid userId" }); @@ -1753,10 +1762,12 @@ router.get( await createCurrentHostResolutionRepository().findHostsByUserId(userId); const exportedHosts = []; + const usedCredentialIds = new Set(); for (const host of allHosts) { - const resolvedHost = - (await resolveHostCredentials(host, userId)) || host; + const resolvedHost = shareMode + ? host + : (await resolveHostCredentials(host, userId)) || host; const exportedConnectionType = (resolvedHost.connectionType as string) || "ssh"; @@ -1770,7 +1781,7 @@ router.get( ip: resolvedHost.ip, port: resolvedHost.port, username: resolvedHost.username, - password: resolvedHost.password || null, + password: shareMode ? null : resolvedHost.password || null, folder: resolvedHost.folder, tags: typeof resolvedHost.tags === "string" @@ -1793,8 +1804,8 @@ router.get( : { ...baseExportData, authType: resolvedHost.authType, - key: resolvedHost.key || null, - keyPassword: resolvedHost.keyPassword || null, + key: shareMode ? null : resolvedHost.key || null, + keyPassword: shareMode ? null : resolvedHost.keyPassword || null, keyType: resolvedHost.keyType || null, credentialId: resolvedHost.credentialId || null, overrideCredentialUsername: @@ -1811,7 +1822,9 @@ router.get( showDockerInSidebar: !!resolvedHost.showDockerInSidebar, showServerStatsInSidebar: !!resolvedHost.showServerStatsInSidebar, defaultPath: resolvedHost.defaultPath, - sudoPassword: resolvedHost.sudoPassword || null, + sudoPassword: shareMode + ? null + : resolvedHost.sudoPassword || null, tunnelConnections: resolvedHost.tunnelConnections ? JSON.parse(resolvedHost.tunnelConnections as string) : [], @@ -1839,22 +1852,92 @@ router.get( socks5Host: resolvedHost.socks5Host || null, socks5Port: resolvedHost.socks5Port || null, socks5Username: resolvedHost.socks5Username || null, - socks5Password: resolvedHost.socks5Password || null, + socks5Password: shareMode + ? null + : resolvedHost.socks5Password || null, socks5ProxyChain: resolvedHost.socks5ProxyChain ? JSON.parse(resolvedHost.socks5ProxyChain as string) : null, }; + if ( + shareMode && + !isRemoteDesktop && + resolvedHost.authType === "credential" && + resolvedHost.credentialId + ) { + usedCredentialIds.add(resolvedHost.credentialId as number); + } + exportedHosts.push(exportData); } - sshLogger.success("All hosts exported with decrypted credentials", { - operation: "hosts_export_all", + if (!shareMode) { + sshLogger.success("All hosts exported with decrypted credentials", { + operation: "hosts_export_all", + count: exportedHosts.length, + userId, + }); + + return res.json({ hosts: exportedHosts }); + } + + const exportedCredentials: Record[] = []; + if (usedCredentialIds.size > 0) { + const credentialRepository = createCurrentCredentialRepository(); + const ownedCredentials = + await credentialRepository.listDecryptedByUserId(userId); + const credentialById = new Map( + ownedCredentials.map((credential) => [credential.id, credential]), + ); + + for (const host of exportedHosts as Record[]) { + const credentialId = host.credentialId as number | null; + if (!credentialId) continue; + const credential = credentialById.get(credentialId); + if (!credential) continue; + + host.credentialAlias = credential.name; + + if ( + !exportedCredentials.some( + (entry) => entry.alias === credential.name, + ) + ) { + exportedCredentials.push({ + alias: credential.name, + name: credential.name, + description: credential.description || null, + folder: credential.folder || null, + tags: + typeof credential.tags === "string" + ? credential.tags.split(",").filter(Boolean) + : [], + authType: credential.authType, + username: credential.username || null, + keyType: credential.keyType || null, + }); + } + } + } + + for (const host of exportedHosts as Record[]) { + delete host.credentialId; + } + + sshLogger.success("All hosts exported for sharing without secrets", { + operation: "hosts_export_all_share", count: exportedHosts.length, + credentialCount: exportedCredentials.length, userId, }); - res.json({ hosts: exportedHosts }); + res.json({ + version: "1", + exportedAt: new Date().toISOString(), + credentials: exportedCredentials, + hosts: exportedHosts, + }); } catch (err) { sshLogger.error("Failed to export all SSH hosts", err, { operation: "hosts_export_all", diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts index 06fbb12e..8be290c4 100644 --- a/src/backend/database/routes/rbac.ts +++ b/src/backend/database/routes/rbac.ts @@ -14,6 +14,7 @@ import { } from "../../utils/permission-catalog.js"; import { createCurrentCredentialRepository, + createCurrentHostFolderRepository, createCurrentHostResolutionRepository, createCurrentRbacAccessRepository, createCurrentRoleRepository, @@ -311,6 +312,225 @@ router.post( }, ); +/** + * @openapi + * /rbac/folder/share: + * post: + * summary: Share all hosts in a folder + * description: Shares every host within a folder (and its subfolders) with one or more users and/or roles at a permission level. Only hosts owned by the caller are shared; skips hosts the caller may not share. + * tags: + * - RBAC + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [folder, targets] + * properties: + * folder: + * type: string + * targets: + * type: array + * items: + * type: object + * properties: + * type: + * type: string + * enum: [user, role] + * id: + * oneOf: + * - type: string + * - type: integer + * permissionLevel: + * type: string + * enum: [connect, view, edit, manage] + * durationHours: + * type: number + * responses: + * 200: + * description: Folder shared successfully. + * 400: + * description: Invalid request body. + * 404: + * description: Folder has no hosts. + * 500: + * description: Failed to share folder. + */ +router.post( + "/folder/share", + authenticateJWT, + async (req: AuthenticatedRequest, res: Response) => { + const userId = req.userId!; + const { folder } = req.body ?? {}; + + if (!isNonEmptyString(folder)) { + return res.status(400).json({ error: "Folder name is required" }); + } + + try { + const targets = parseShareTargets(req.body ?? {}); + if (!targets) { + return res.status(400).json({ + error: + "targets must be a non-empty array of { type: 'user'|'role', id } entries", + }); + } + + const { durationHours, permissionLevel = "connect" } = req.body; + + if (!isSharePermissionLevel(permissionLevel)) { + return res.status(400).json({ + error: "Invalid permission level", + validLevels: SHARE_PERMISSION_LEVELS, + }); + } + + const userRepository = createCurrentUserRepository(); + const roleRepository = createCurrentRoleRepository(); + for (const target of targets) { + if (target.type === "user") { + const targetUser = await userRepository.findById(target.id as string); + if (!targetUser) { + return res.status(404).json({ + error: "Target user not found", + targetId: target.id, + }); + } + } else { + const targetRole = await roleRepository.findRoleById( + target.id as number, + ); + if (!targetRole) { + return res.status(404).json({ + error: "Target role not found", + targetId: target.id, + }); + } + } + } + + const hostsInFolder = + await createCurrentHostFolderRepository().listHostsInFolder( + userId, + folder, + ); + if (hostsInFolder.length === 0) { + return res.status(404).json({ error: "Folder has no hosts" }); + } + + const expiresAt = expiryFromDuration(durationHours); + const rbacAccessRepository = createCurrentRbacAccessRepository(); + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); + const secretsManager = SharedHostSecretsManager.getInstance(); + + const hostResults: Array<{ + hostId: number; + shared: boolean; + reason?: string; + }> = []; + + for (const host of hostsInFolder) { + if (targets.some((t) => t.type === "user" && t.id === host.userId)) { + hostResults.push({ + hostId: host.id, + shared: false, + reason: "owner", + }); + continue; + } + + const sharing = await canManageHostSharing(userId, host.id); + if (!sharing.allowed) { + hostResults.push({ + hostId: host.id, + shared: false, + reason: "forbidden", + }); + continue; + } + + for (const target of targets) { + const accessGrant = await rbacAccessRepository.upsertHostAccess({ + hostId: host.id, + grantedBy: userId, + permissionLevel, + expiresAt, + ...(target.type === "user" + ? { + targetType: "user" as const, + targetUserId: target.id as string, + } + : { + targetType: "role" as const, + targetRoleId: target.id as number, + }), + }); + + try { + if (target.type === "user") { + await secretsManager.snapshotForUser( + accessGrant.id, + host.id, + target.id as string, + host.userId, + ); + } else { + await secretsManager.snapshotForRole( + accessGrant.id, + host.id, + target.id as number, + host.userId, + ); + } + } catch (snapshotError) { + databaseLogger.warn("Share created but secret snapshot failed", { + operation: "rbac_folder_share_snapshot_failed", + hostId: host.id, + accessId: accessGrant.id, + error: + snapshotError instanceof Error + ? snapshotError.message + : "Unknown error", + }); + } + } + + hostResults.push({ hostId: host.id, shared: true }); + } + + const sharedCount = hostResults.filter((r) => r.shared).length; + + databaseLogger.success("Folder shared successfully", { + operation: "rbac_folder_share_success", + userId, + folder, + hostsShared: sharedCount, + targets: targets.length, + permissionLevel, + }); + + res.json({ + success: true, + message: "Folder shared successfully", + permissionLevel, + expiresAt, + hostsShared: sharedCount, + hostsTotal: hostsInFolder.length, + hostResults, + }); + } catch (error) { + databaseLogger.error("Failed to share folder", error, { + operation: "share_folder", + folder, + userId, + }); + res.status(500).json({ error: "Failed to share folder" }); + } + }, +); + /** * @openapi * /rbac/host/{id}/access/{accessId}: diff --git a/src/backend/hosts/host-resolver.ts b/src/backend/hosts/host-resolver.ts index 859d8e09..df5d8bd4 100644 --- a/src/backend/hosts/host-resolver.ts +++ b/src/backend/hosts/host-resolver.ts @@ -122,35 +122,61 @@ export async function resolveHostById( repository, ); if (!resolved) return null; - } else if (host.credentialId) { - try { - const cred = (await repository.findCredentialByIdForUser( - host.credentialId as number, - ownerId, - )) as Record | null; - - if (cred) { - host.password = pickResolvedPassword(host.password, cred.password); - // Prefer the normalised private key; fall back to raw key field - host.key = (cred.privateKey || cred.key) as string | null; - host.keyPassword = cred.keyPassword; - host.keyType = cred.keyType; - // CA-signed certificate for cert-based auth - (host as Record).certPublicKey = - cred.certPublicKey || null; - host.username = pickResolvedUsername( - host.username, - cred.username, - host.overrideCredentialUsername, + } else { + let effectiveCredentialId = host.credentialId as number | null | undefined; + if ( + !effectiveCredentialId && + host.authType === "credential" && + host.folder + ) { + try { + effectiveCredentialId = await repository.findFolderCredentialId( + ownerId, + host.folder as string, ); - host.authType = host.key ? "key" : host.password ? "password" : "none"; + } catch (e) { + sshLogger.warn("Failed to resolve folder credential for host", { + operation: "host_resolver_folder_credential", + hostId, + error: e instanceof Error ? e.message : "Unknown", + }); + } + } + + if (effectiveCredentialId) { + try { + const cred = (await repository.findCredentialByIdForUser( + effectiveCredentialId, + ownerId, + )) as Record | null; + + if (cred) { + host.password = pickResolvedPassword(host.password, cred.password); + // Prefer the normalised private key; fall back to raw key field + host.key = (cred.privateKey || cred.key) as string | null; + host.keyPassword = cred.keyPassword; + host.keyType = cred.keyType; + // CA-signed certificate for cert-based auth + (host as Record).certPublicKey = + cred.certPublicKey || null; + host.username = pickResolvedUsername( + host.username, + cred.username, + host.overrideCredentialUsername, + ); + host.authType = host.key + ? "key" + : host.password + ? "password" + : "none"; + } + } catch (e) { + sshLogger.warn("Failed to resolve credential for host", { + operation: "host_resolver_credential", + hostId, + error: e instanceof Error ? e.message : "Unknown", + }); } - } catch (e) { - sshLogger.warn("Failed to resolve credential for host", { - operation: "host_resolver_credential", - hostId, - error: e instanceof Error ? e.message : "Unknown", - }); } } diff --git a/src/backend/hosts/metrics/index.ts b/src/backend/hosts/metrics/index.ts index b934a34d..8f71f1d9 100644 --- a/src/backend/hosts/metrics/index.ts +++ b/src/backend/hosts/metrics/index.ts @@ -32,6 +32,7 @@ import { collectSystemMetrics } from "./widgets/system-collector.js"; import { collectLoginStats } from "./widgets/login-stats-collector.js"; import { collectPortsMetrics } from "./widgets/ports-collector.js"; import { collectFirewallMetrics } from "./widgets/firewall-collector.js"; +import { collectTemperatureMetrics } from "./widgets/temperature-collector.js"; import { createSocks5Connection, type SOCKS5Config, @@ -146,6 +147,7 @@ const DEFAULT_STATS_CONFIG: StatsConfig = { "processes", "ports", "firewall", + "temperature", ], statusCheckEnabled: true, statusCheckInterval: 60, @@ -1582,6 +1584,21 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ // expected } + let temperature: { + source: "sysfs" | "sensors" | "none"; + highestCelsius: number | null; + sensors: Array<{ label: string; celsius: number }>; + } = { + source: "none", + highestCelsius: null, + sensors: [], + }; + try { + temperature = await collectTemperatureMetrics(client); + } catch { + // expected + } + const result = { cpu, memory, @@ -1593,6 +1610,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ login_stats, ports, firewall, + temperature, }; metricsCache.set(host.id, result); diff --git a/src/backend/tests/database/repositories/host-folder-repository.test.ts b/src/backend/tests/database/repositories/host-folder-repository.test.ts index ca7df0c6..c1a03344 100644 --- a/src/backend/tests/database/repositories/host-folder-repository.test.ts +++ b/src/backend/tests/database/repositories/host-folder-repository.test.ts @@ -139,6 +139,7 @@ describe("HostFolderRepository", () => { name TEXT NOT NULL, color TEXT, icon TEXT, + credential_id INTEGER, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -216,6 +217,7 @@ describe("HostFolderRepository", () => { "prod", "#abcdef", "folder", + undefined, "2026-02-01T00:00:00.000Z", ), ).resolves.toMatchObject({ @@ -228,6 +230,7 @@ describe("HostFolderRepository", () => { "new", null, null, + null, "2026-03-01T00:00:00.000Z", ), ).resolves.toMatchObject({ @@ -237,6 +240,28 @@ describe("HostFolderRepository", () => { expect(writes).toBe(2); }); + it("assigns a credential to a folder and resolves it for nested paths", async () => { + const { repository } = await createRepository(); + + await expect( + repository.upsertMetadata( + "user-1", + "prod", + undefined, + undefined, + 1, + "2026-02-01T00:00:00.000Z", + ), + ).resolves.toMatchObject({ + created: false, + folder: { credentialId: 1 }, + }); + + const folders = await repository.listFolders("user-1"); + const prodFolder = folders.find((f) => f.name === "prod"); + expect(prodFolder?.credentialId).toBe(1); + }); + it("lists and deletes hosts and folder records in a folder tree", async () => { let writes = 0; const { repository, sqlite } = await createRepository(() => { diff --git a/src/backend/tests/database/repositories/host-resolution-repository.test.ts b/src/backend/tests/database/repositories/host-resolution-repository.test.ts index e90eb576..806fee12 100644 --- a/src/backend/tests/database/repositories/host-resolution-repository.test.ts +++ b/src/backend/tests/database/repositories/host-resolution-repository.test.ts @@ -165,6 +165,17 @@ describe("HostResolutionRepository", () => { override_credential_id INTEGER ); + CREATE TABLE ssh_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT, + icon TEXT, + credential_id INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); INSERT INTO ssh_data ( @@ -185,6 +196,11 @@ describe("HostResolutionRepository", () => { host_id, user_id, granted_by, permission_level, override_credential_id ) VALUES (1, 'user-2', 'user-1', 'execute', 8); + INSERT INTO ssh_folders (user_id, name, credential_id) + VALUES + ('user-1', 'switches', 7), + ('user-1', 'switches / floor1', NULL), + ('user-1', 'no-cred', NULL); `); return new HostResolutionRepository(context, onWrite); @@ -492,4 +508,24 @@ describe("HostResolutionRepository", () => { repository.findOverrideCredentialId(1, "user-1"), ).resolves.toBeNull(); }); + + it("resolves a folder's assigned credential, walking up to parent folders", async () => { + const repository = await createRepository(); + + await expect( + repository.findFolderCredentialId("user-1", "switches"), + ).resolves.toBe(7); + await expect( + repository.findFolderCredentialId("user-1", "switches / floor1"), + ).resolves.toBe(7); + await expect( + repository.findFolderCredentialId("user-1", "no-cred"), + ).resolves.toBeNull(); + await expect( + repository.findFolderCredentialId("user-1", "unknown"), + ).resolves.toBeNull(); + await expect( + repository.findFolderCredentialId("user-1", ""), + ).resolves.toBeNull(); + }); }); diff --git a/src/backend/tests/hosts/host-resolver.test.ts b/src/backend/tests/hosts/host-resolver.test.ts index 9729fd97..68a9fc56 100644 --- a/src/backend/tests/hosts/host-resolver.test.ts +++ b/src/backend/tests/hosts/host-resolver.test.ts @@ -8,6 +8,7 @@ const state = vi.hoisted(() => ({ credentials: new Map>(), sharedSecret: null as Record | null, auditCalls: [] as Record[], + folderCredentialId: null as number | null, })); vi.mock("../../database/repositories/factory.js", () => ({ @@ -17,6 +18,7 @@ vi.mock("../../database/repositories/factory.js", () => ({ findOverrideCredentialId: async () => state.overrideCredentialId, findCredentialByIdForUser: async (credentialId: number, userId: string) => state.credentials.get(`${credentialId}:${userId}`) ?? null, + findFolderCredentialId: async () => state.folderCredentialId, }), createCurrentVaultProfileRepository: () => ({ findById: async () => null, @@ -101,6 +103,7 @@ beforeEach(() => { state.credentials.clear(); state.sharedSecret = null; state.auditCalls = []; + state.folderCredentialId = null; }); describe("resolveHostById", () => { @@ -138,6 +141,63 @@ describe("resolveHostById", () => { expect(host.sudoPassword).toBe("owner-sudo"); }); + it("falls back to the host's folder-assigned credential when none is set on the host", async () => { + state.host = baseHost({ + authType: "credential", + credentialId: null, + folder: "switches", + username: "", + password: null, + }); + state.folderCredentialId = 11; + state.credentials.set("11:owner", { + id: 11, + username: "folder-user", + authType: "password", + password: "folder-pass", + privateKey: null, + key: null, + keyPassword: null, + keyType: null, + }); + + const host = (await resolveHostById(42, "owner")) as Record< + string, + unknown + >; + expect(host.password).toBe("folder-pass"); + expect(host.username).toBe("folder-user"); + expect(host.authType).toBe("password"); + }); + + it("prefers the host's own credential over its folder's credential", async () => { + state.host = baseHost({ + authType: "credential", + credentialId: 9, + folder: "switches", + username: "", + password: null, + }); + state.folderCredentialId = 11; + state.credentials.set("9:owner", { + id: 9, + username: "host-user", + authType: "password", + password: "host-pass", + privateKey: null, + key: null, + keyPassword: null, + keyType: null, + }); + + const host = (await resolveHostById(42, "owner")) as Record< + string, + unknown + >; + expect(host.username).toBe("host-user"); + expect(host.password).toBe("host-pass"); + }); + it("uses the share snapshot for a non-owner and strips owner-only secrets", async () => { state.host = baseHost({ username: "" }); state.sharedSecret = { diff --git a/src/types/index.ts b/src/types/index.ts index 3b2b9a03..65e4dcbe 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -343,6 +343,7 @@ export interface SSHFolder { name: string; color?: string; icon?: string; + credentialId?: number | null; createdAt: string; updatedAt: string; } diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 90151578..9cf65840 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -221,6 +221,7 @@ export type HostFolder = { path?: string; color?: string; icon?: string; + credentialId?: number | null; }; export type TabType = diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 5c905e55..0c866d87 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -141,7 +141,10 @@ import { quickConnectHostToPayload } from "@/sidebar/quick-connect-host"; function buildHostTree( hosts: SSHHostWithStatus[], - folderMeta?: Map, + folderMeta?: Map< + string, + { color?: string; icon?: string; credentialId?: number | null } + >, ): HostFolder { const root: HostFolder = { name: "root", children: [] }; const folderMap = new Map(); @@ -159,6 +162,7 @@ function buildHostTree( path: accumulated, color: meta?.color, icon: meta?.icon, + credentialId: meta?.credentialId ?? null, children: [], }; folderMap.set(accumulated, folder); @@ -798,11 +802,15 @@ export function AppShell({ ]); const converted = raw.map(sshHostToHost); setAllHosts(converted); - const folderMeta = new Map(); + const folderMeta = new Map< + string, + { color?: string; icon?: string; credentialId?: number | null } + >(); for (const f of folders) { folderMeta.set(f.name, { color: f.color ?? undefined, icon: f.icon ?? undefined, + credentialId: f.credentialId ?? null, }); } setRealHostTree(buildHostTree(raw, folderMeta)); diff --git a/src/ui/api/credentials-api.ts b/src/ui/api/credentials-api.ts index 7c0f1e7b..01e7bcd7 100644 --- a/src/ui/api/credentials-api.ts +++ b/src/ui/api/credentials-api.ts @@ -200,6 +200,7 @@ export async function updateFolderMetadata( name: string, color?: string, icon?: string, + credentialId?: number | null, ): Promise { try { sshLogger.info("Updating folder metadata", { @@ -207,12 +208,14 @@ export async function updateFolderMetadata( name, color, icon, + credentialId, }); await authApi.put("/host/folders/metadata", { name, color, icon, + credentialId, }); sshLogger.success("Folder metadata updated successfully", { diff --git a/src/ui/api/rbac-api.ts b/src/ui/api/rbac-api.ts index 63f2553a..808843f8 100644 --- a/src/ui/api/rbac-api.ts +++ b/src/ui/api/rbac-api.ts @@ -124,6 +124,31 @@ export async function shareHost( } } +export async function shareFolder( + folder: string, + shareData: { + targets: ShareTarget[]; + permissionLevel: SharePermissionLevel; + durationHours?: number; + }, +): Promise<{ + success: boolean; + expiresAt: string | null; + hostsShared: number; + hostsTotal: number; + hostResults: Array<{ hostId: number; shared: boolean; reason?: string }>; +}> { + try { + const response = await rbacApi.post("/rbac/folder/share", { + folder, + ...shareData, + }); + return response.data; + } catch (error) { + throw handleApiError(error, "share folder"); + } +} + export async function updateHostAccess( hostId: number, accessId: number, diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 3a94e234..3d85637d 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1002,6 +1002,9 @@ "folderNestingHint": "Use / to separate levels and create nested folders.", "folderColor": "Color", "folderIcon": "Icon", + "folderCredential": "Credential", + "folderCredentialNone": "No credential assigned", + "folderCredentialHint": "Hosts in this folder that use \"Stored credential\" auth without their own credential selected will inherit this one.", "folderPreview": "Preview", "folderNameFallback": "Untitled folder", "createFolderButton": "Create folder", @@ -1183,6 +1186,10 @@ "filterTagsGroup": "Tags", "shareHost": "Share Host", "shareHostTitle": "Share: {{name}}", + "shareFolder": "Share Folder", + "shareFolderTitle": "Share folder: {{name}}", + "folderSharedSuccessfully": "Shared {{count}} host(s) in folder", + "failedToShareFolder": "Failed to share folder", "sharing": { "loadError": "Failed to load sharing data. Please try again.", "shareWithSection": "Share with", @@ -1223,6 +1230,7 @@ "shareWithCount": "Share ({{count}})", "currentAccess": "Current access", "noAccessEntries": "This host has not been shared yet", + "folderShareSummary": "Shared {{shared}} of {{total}} host(s) in this folder", "grantedBy": "Granted by", "expires": "Expires", "expired": "Expired", diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 98bce891..6d223c2f 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -2120,6 +2120,7 @@ export { assignRoleToUser, removeRoleFromUser, shareHost, + shareFolder, updateHostAccess, getHostAccess, revokeHostAccess, diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx index 4341c19d..a44bffe1 100644 --- a/src/ui/sidebar/AdminSettingsPanel.tsx +++ b/src/ui/sidebar/AdminSettingsPanel.tsx @@ -110,8 +110,8 @@ export function AdminSettingsPanel({ onOpenHostTab?: (host: Host) => void; } = {}) { const { t } = useTranslation(); - const [openSection, setOpenSection] = useState( - "general", + const [openSections, setOpenSections] = useState>( + () => new Set(["general"]), ); const [manageUser, setManageUser] = useState(null); const [allowRegistration, setAllowRegistration] = useState(true); @@ -347,7 +347,12 @@ export function AdminSettingsPanel({ } function toggle(id: AdminSection) { - setOpenSection((prev) => (prev === id ? null : id)); + setOpenSections((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); } async function handleSaveHostDefaults() { @@ -854,7 +859,7 @@ export function AdminSettingsPanel({ return (
toggle("general")} allowRegistration={allowRegistration} handleToggleRegistration={handleToggleRegistration} @@ -891,7 +896,7 @@ export function AdminSettingsPanel({ /> toggle("sso")} providers={ssoProviders} onAddProvider={handleAddProvider} @@ -908,7 +913,7 @@ export function AdminSettingsPanel({ /> toggle("users")} users={users} setUsers={setUsers} @@ -924,7 +929,7 @@ export function AdminSettingsPanel({ /> toggle("sessions")} sessions={sessions} setSessions={setSessions} @@ -932,7 +937,7 @@ export function AdminSettingsPanel({ /> toggle("roles")} roles={roles} setRoles={setRoles} @@ -949,7 +954,7 @@ export function AdminSettingsPanel({ /> toggle("host-defaults")} defaults={hostDefaults} setDefaults={setHostDefaults} @@ -957,7 +962,7 @@ export function AdminSettingsPanel({ /> toggle("database")} importFile={importFile} setImportFile={setImportFile} @@ -968,7 +973,7 @@ export function AdminSettingsPanel({ /> toggle("ssl")} settings={acmeSettings} setSettings={setAcmeSettings} @@ -980,7 +985,7 @@ export function AdminSettingsPanel({ /> toggle("api-keys")} apiKeys={apiKeys} setApiKeys={setApiKeys} @@ -1001,7 +1006,7 @@ export function AdminSettingsPanel({ /> toggle("audit-log")} users={users} /> diff --git a/src/ui/sidebar/FolderMetadataDialog.tsx b/src/ui/sidebar/FolderMetadataDialog.tsx index 5fb0f5f3..121e8e89 100644 --- a/src/ui/sidebar/FolderMetadataDialog.tsx +++ b/src/ui/sidebar/FolderMetadataDialog.tsx @@ -18,13 +18,17 @@ import { IconPicker, } from "@/components/folder-style"; import { normalizePath, splitPath } from "./FolderPathPicker"; +import { getCredentials } from "@/main-axios"; export type FolderMetadataValue = { name: string; color: string; icon: string; + credentialId: number | null; }; +type CredentialOption = { id: string; name: string; username?: string }; + export function FolderMetadataDialog({ open, mode, @@ -34,7 +38,12 @@ export function FolderMetadataDialog({ }: { open: boolean; mode: "create" | "edit"; - initial?: { name: string; color?: string; icon?: string }; + initial?: { + name: string; + color?: string; + icon?: string; + credentialId?: number | null; + }; onOpenChange: (v: boolean) => void; onSubmit: (value: FolderMetadataValue) => void; }) { @@ -42,19 +51,45 @@ export function FolderMetadataDialog({ const [name, setName] = useState(""); const [color, setColor] = useState(DEFAULT_FOLDER_COLOR); const [icon, setIcon] = useState(DEFAULT_FOLDER_ICON); + const [credentialId, setCredentialId] = useState(""); + const [credentials, setCredentials] = useState([]); useEffect(() => { if (open) { setName(initial?.name ?? ""); setColor(initial?.color ?? DEFAULT_FOLDER_COLOR); setIcon(initial?.icon ?? DEFAULT_FOLDER_ICON); + setCredentialId( + initial?.credentialId ? String(initial.credentialId) : "", + ); } }, [open, initial]); + useEffect(() => { + if (!open) return; + getCredentials() + .then((data) => { + const list = Array.isArray(data) ? data : []; + setCredentials( + list.map((c) => ({ + id: String(c.id), + name: String(c.name ?? ""), + username: c.username ? String(c.username) : undefined, + })), + ); + }) + .catch(() => setCredentials([])); + }, [open]); + function handleSubmit() { const normalized = normalizePath(name); if (!normalized) return; - onSubmit({ name: normalized, color, icon }); + onSubmit({ + name: normalized, + color, + icon, + credentialId: credentialId ? Number(credentialId) : null, + }); onOpenChange(false); } @@ -101,6 +136,26 @@ export function FolderMetadataDialog({
+
+ + +

+ {t("hosts.folderCredentialHint")} +

+
- {/* Current access: takes remaining space, scrolls independently */} -
-
- - {t("hosts.sharing.currentAccess")} - {accessList.length > 0 && ( - - ({accessList.length}) - - )} -
-
- {accessList.length === 0 && ( -
- {t("hosts.sharing.noAccessEntries")} -
- )} - {accessList.map((record) => { - const expired = - record.expiresAt && new Date(record.expiresAt) < new Date(); - return ( -
-
-
- {record.targetType === "user" ? ( - - ) : ( - - )} - - {record.username ?? - record.roleDisplayName ?? - record.roleName ?? - record.userId ?? - record.roleId} - -
-
- - - - - - {PERMISSION_LEVELS.map((level) => ( - handleLevelChange(record, level)} - > - {record.permissionLevel === level ? ( - - ) : ( - - )} - {t(`hosts.sharing.levels.${level}.label`)} - - ))} - - - -
-
-
- - {t("hosts.sharing.grantedBy")}:{" "} - - {record.grantedByUsername ?? "?"} - - - - {t("hosts.sharing.expires")}:{" "} - {expired ? ( - - - {t("hosts.sharing.expired")} - - ) : record.expiresAt ? ( - - {new Date(record.expiresAt).toLocaleString()} - - ) : ( - - {t("hosts.sharing.never")} - - )} - -
-
- ); + {/* Folder share summary */} + {isFolderShare && folderShareSummary && ( +
+ + {t("hosts.sharing.folderShareSummary", { + shared: folderShareSummary.hostsShared, + total: folderShareSummary.hostsTotal, })}
-
+ )} + + {/* Current access: takes remaining space, scrolls independently */} + {!isFolderShare && ( +
+
+ + {t("hosts.sharing.currentAccess")} + {accessList.length > 0 && ( + + ({accessList.length}) + + )} +
+
+ {accessList.length === 0 && ( +
+ {t("hosts.sharing.noAccessEntries")} +
+ )} + {accessList.map((record) => { + const expired = + record.expiresAt && new Date(record.expiresAt) < new Date(); + return ( +
+
+
+ {record.targetType === "user" ? ( + + ) : ( + + )} + + {record.username ?? + record.roleDisplayName ?? + record.roleName ?? + record.userId ?? + record.roleId} + +
+
+ + + + + + {PERMISSION_LEVELS.map((level) => ( + handleLevelChange(record, level)} + > + {record.permissionLevel === level ? ( + + ) : ( + + )} + {t(`hosts.sharing.levels.${level}.label`)} + + ))} + + + +
+
+
+ + {t("hosts.sharing.grantedBy")}:{" "} + + {record.grantedByUsername ?? "?"} + + + + {t("hosts.sharing.expires")}:{" "} + {expired ? ( + + + {t("hosts.sharing.expired")} + + ) : record.expiresAt ? ( + + {new Date(record.expiresAt).toLocaleString()} + + ) : ( + + {t("hosts.sharing.never")} + + )} + +
+
+ ); + })} +
+
+ )}
); } diff --git a/src/ui/sidebar/SidebarTree.tsx b/src/ui/sidebar/SidebarTree.tsx index c2ae9c5e..428dec85 100644 --- a/src/ui/sidebar/SidebarTree.tsx +++ b/src/ui/sidebar/SidebarTree.tsx @@ -71,6 +71,7 @@ import { canShareHost, } from "@/sidebar/host-permissions"; import { FolderMetadataDialog } from "./FolderMetadataDialog"; +import { HostShareModal } from "@/sidebar/HostShareModal"; import { useStatusColorScheme, getStatusClasses, @@ -319,7 +320,7 @@ export function HostItem({ const metricsEnabled = host.enableSsh && host.statsConfig?.metricsEnabled !== false; const [trayOnClick, setTrayOnClick] = useState( - () => localStorage.getItem("hostTrayOnClick") === "true", + () => localStorage.getItem("hostTrayOnClick") !== "false", ); const [showHostTags, setShowHostTags] = useState(() => { const v = localStorage.getItem("showHostTags"); @@ -362,7 +363,7 @@ export function HostItem({ useEffect(() => { const handler = () => - setTrayOnClick(localStorage.getItem("hostTrayOnClick") === "true"); + setTrayOnClick(localStorage.getItem("hostTrayOnClick") !== "false"); window.addEventListener("storage", handler); window.addEventListener("hostTrayOnClickChanged", handler); return () => { @@ -1561,6 +1562,7 @@ export function FolderItem({ onManageFolder, onDeleteFolder, onOpenAllSessions, + onShareFolder, onMoveHostsToFolder, draggedHostIds, onDragHostStart, @@ -1591,6 +1593,7 @@ export function FolderItem({ onManageFolder: (folder: HostFolder) => void; onDeleteFolder: (folder: HostFolder) => void; onOpenAllSessions: (folder: HostFolder) => void; + onShareFolder?: (folder: HostFolder) => void; onMoveHostsToFolder: (hostIds: string[], targetPath: string) => void; draggedHostIds: string[] | null; onDragHostStart: (hostId: string) => void; @@ -1674,6 +1677,18 @@ export function FolderItem({ > + {onShareFolder && ( + { + e.stopPropagation(); + onShareFolder(folder); + }} + > + + + )} (null); + const [shareFolderTarget, setShareFolderTarget] = useState( + null, + ); const [compactHostView, setCompactHostView] = useState( () => localStorage.getItem("compactHostView") === "true", ); const [trayOnClick, setTrayOnClick] = useState( - () => localStorage.getItem("hostTrayOnClick") === "true", + () => localStorage.getItem("hostTrayOnClick") !== "false", ); useEffect(() => { @@ -1833,7 +1852,7 @@ export function SidebarTree({ useEffect(() => { const handler = () => - setTrayOnClick(localStorage.getItem("hostTrayOnClick") === "true"); + setTrayOnClick(localStorage.getItem("hostTrayOnClick") !== "false"); window.addEventListener("storage", handler); window.addEventListener("hostTrayOnClickChanged", handler); return () => { @@ -1886,6 +1905,7 @@ export function SidebarTree({ name: string; color: string; icon: string; + credentialId: number | null; }) { const existing = folderDialog?.folder; try { @@ -1898,9 +1918,19 @@ export function SidebarTree({ if (newPath !== oldPath) { await renameFolder(oldPath, newPath); } - await updateFolderMetadata(newPath, value.color, value.icon); + await updateFolderMetadata( + newPath, + value.color, + value.icon, + value.credentialId, + ); } else { - await updateFolderMetadata(value.name, value.color, value.icon); + await updateFolderMetadata( + value.name, + value.color, + value.icon, + value.credentialId, + ); } window.dispatchEvent(new CustomEvent("termix:hosts-changed")); toast.success(t("hosts.folderSaved")); @@ -2224,6 +2254,9 @@ export function SidebarTree({ onManageFolder={handleManageFolder} onDeleteFolder={handleDeleteFolder} onOpenAllSessions={handleOpenAllSessions} + onShareFolder={(folder) => + setShareFolderTarget(folder.path ?? folder.name) + } onMoveHostsToFolder={handleMoveHostsToFolder} draggedHostIds={draggedHostIds} onDragHostStart={handleDragHostStart} @@ -2541,12 +2574,20 @@ export function SidebarTree({ name: folderDialog.folder.name, color: folderDialog.folder.color, icon: folderDialog.folder.icon, + credentialId: folderDialog.folder.credentialId, } : undefined } onOpenChange={(v) => !v && setFolderDialog(null)} onSubmit={handleSaveFolderMetadata} /> + + setShareFolderTarget(null)} + host={null} + folder={shareFolderTarget} + /> ); } diff --git a/src/ui/sidebar/UserProfilePanel.tsx b/src/ui/sidebar/UserProfilePanel.tsx index 72e779bb..c20ec6e5 100644 --- a/src/ui/sidebar/UserProfilePanel.tsx +++ b/src/ui/sidebar/UserProfilePanel.tsx @@ -464,8 +464,8 @@ export function UserProfilePanel({ "one-dark": t("newUi.sidebar.userProfile.themeOneDark"), gruvbox: t("newUi.sidebar.userProfile.themeGruvbox"), }; - const [openSection, setOpenSection] = useState( - "account", + const [openSections, setOpenSections] = useState>( + () => new Set(["account"]), ); // User info @@ -548,7 +548,7 @@ export function UserProfilePanel({ return v !== null ? v === "true" : true; }); const [hostTrayOnClick, setHostTrayOnClick] = useState( - () => localStorage.getItem("hostTrayOnClick") === "true", + () => localStorage.getItem("hostTrayOnClick") !== "false", ); const [compactHostView, setCompactHostView] = useState( () => localStorage.getItem("compactHostView") === "true", @@ -790,8 +790,8 @@ export function UserProfilePanel({ setShowHostTags(true); localStorage.setItem("showHostTags", "true"); window.dispatchEvent(new CustomEvent("showHostTagsChanged")); - setHostTrayOnClick(false); - localStorage.setItem("hostTrayOnClick", "false"); + setHostTrayOnClick(true); + localStorage.setItem("hostTrayOnClick", "true"); setCompactHostView(false); localStorage.setItem("compactHostView", "false"); window.dispatchEvent(new CustomEvent("compactHostViewChanged")); @@ -824,7 +824,7 @@ export function UserProfilePanel({ commandAutocomplete: false, commandPaletteEnabled: true, showHostTags: true, - hostTrayOnClick: false, + hostTrayOnClick: true, compactHostView: false, pinAppRail: false, expandAppRailOnHover: true, @@ -893,7 +893,7 @@ export function UserProfilePanel({ localStorage.setItem("showHostTags", String(restoredHostTags)); window.dispatchEvent(new CustomEvent("showHostTagsChanged")); - const restoredTrayOnClick = restore("hostTrayOnClick", "false") === "true"; + const restoredTrayOnClick = restore("hostTrayOnClick", "true") !== "false"; setHostTrayOnClick(restoredTrayOnClick); localStorage.setItem("hostTrayOnClick", String(restoredTrayOnClick)); @@ -1001,7 +1001,12 @@ export function UserProfilePanel({ } function toggle(id: UserProfileSection) { - setOpenSection((prev) => (prev === id ? null : id)); + setOpenSections((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); } async function handleStartTotpSetup() { @@ -1200,7 +1205,7 @@ export function UserProfilePanel({ id="account" label={t("newUi.sidebar.userProfile.sectionAccount")} icon={} - open={openSection === "account"} + open={openSections.has("account")} onToggle={() => toggle("account")} >
@@ -1367,7 +1372,7 @@ export function UserProfilePanel({ id="appearance" label={t("newUi.sidebar.userProfile.sectionAppearance")} icon={} - open={openSection === "appearance"} + open={openSections.has("appearance")} onToggle={() => toggle("appearance")} >
@@ -1873,7 +1878,7 @@ export function UserProfilePanel({ id="security" label={t("newUi.sidebar.userProfile.sectionSecurity")} icon={} - open={openSection === "security"} + open={openSections.has("security")} onToggle={() => toggle("security")} >
@@ -2204,7 +2209,7 @@ export function UserProfilePanel({ id="api-keys" label={t("newUi.sidebar.userProfile.sectionApiKeys")} icon={} - open={openSection === "api-keys"} + open={openSections.has("api-keys")} onToggle={() => toggle("api-keys")} >
@@ -2310,7 +2315,7 @@ export function UserProfilePanel({ id="c2s-tunnels" label={t("newUi.sidebar.userProfile.sectionC2sTunnels")} icon={} - open={openSection === "c2s-tunnels"} + open={openSections.has("c2s-tunnels")} onToggle={() => toggle("c2s-tunnels")} >