fix: general qol additions

This commit is contained in:
LukeGus
2026-07-20 01:28:17 -05:00
parent 8da7b25c81
commit 7ba45969f6
24 changed files with 963 additions and 208 deletions
+16 -1
View File
@@ -390,9 +390,11 @@ async function initializeCompleteDatabase(): Promise<void> {
name TEXT NOT NULL, name TEXT NOT NULL,
color TEXT, color TEXT,
icon TEXT, icon TEXT,
credential_id INTEGER,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_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 ( 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 { try {
sqlite.prepare("SELECT sudo_password FROM ssh_data LIMIT 1").get(); sqlite.prepare("SELECT sudo_password FROM ssh_data LIMIT 1").get();
} catch { } catch {
+3
View File
@@ -462,6 +462,9 @@ export const sshFolders = sqliteTable("ssh_folders", {
name: text("name").notNull(), name: text("name").notNull(),
color: text("color"), color: text("color"),
icon: text("icon"), icon: text("icon"),
credentialId: integer("credential_id").references(() => sshCredentials.id, {
onDelete: "set null",
}),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
@@ -72,13 +72,20 @@ export class HostFolderRepository {
name: string, name: string,
color: string | null | undefined, color: string | null | undefined,
icon: string | null | undefined, icon: string | null | undefined,
credentialId?: number | null,
now = new Date().toISOString(), now = new Date().toISOString(),
): Promise<{ folder: HostFolderRecord; created: boolean }> { ): Promise<{ folder: HostFolderRecord; created: boolean }> {
const existing = await this.findFolder(userId, name); const existing = await this.findFolder(userId, name);
if (existing) { if (existing) {
const [updated] = await this.context.drizzle const [updated] = await this.context.drizzle
.update(sshFolders) .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))) .where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
.returning(); .returning();
@@ -93,6 +100,7 @@ export class HostFolderRepository {
name, name,
color, color,
icon, icon,
credentialId: credentialId ?? null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
@@ -1,5 +1,5 @@
import { and, eq, inArray, isNotNull } from "drizzle-orm"; 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 type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js"; import { DataCrypto } from "../../utils/data-crypto.js";
@@ -315,6 +315,34 @@ export class HostResolutionRepository {
return rows[0]?.overrideCredentialId ?? null; 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<number | null> {
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<T extends Record<string, unknown>>( private decryptOne<T extends Record<string, unknown>>(
tableName: "ssh_data" | "ssh_credentials", tableName: "ssh_data" | "ssh_credentials",
record: T | undefined, record: T | undefined,
@@ -3,6 +3,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import { databaseLogger, sshLogger } from "../../utils/logger.js"; import { databaseLogger, sshLogger } from "../../utils/logger.js";
import { import {
createCurrentCommandHistoryRepository, createCurrentCommandHistoryRepository,
createCurrentCredentialRepository,
createCurrentFileManagerBookmarkRepository, createCurrentFileManagerBookmarkRepository,
createCurrentHostFolderRepository, createCurrentHostFolderRepository,
createCurrentRecentActivityRepository, createCurrentRecentActivityRepository,
@@ -138,7 +139,7 @@ export function registerHostFolderRoutes(
* /host/folders/metadata: * /host/folders/metadata:
* put: * put:
* summary: Update folder metadata * 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: * tags:
* - SSH * - SSH
* requestBody: * requestBody:
@@ -154,6 +155,9 @@ export function registerHostFolderRoutes(
* type: string * type: string
* icon: * icon:
* type: string * type: string
* credentialId:
* type: integer
* nullable: true
* responses: * responses:
* 200: * 200:
* description: Folder metadata updated successfully. * description: Folder metadata updated successfully.
@@ -167,19 +171,46 @@ export function registerHostFolderRoutes(
authenticateJWT, authenticateJWT,
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
const { name, color, icon } = req.body; const { name, color, icon, credentialId } = req.body;
if (!isNonEmptyString(userId) || !name) { if (!isNonEmptyString(userId) || !name) {
return res.status(400).json({ error: "Folder name is required" }); 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 { try {
if (normalizedCredentialId) {
const credential =
await createCurrentCredentialRepository().findByIdForUser(
userId,
normalizedCredentialId,
);
if (!credential) {
return res.status(404).json({ error: "Credential not found" });
}
}
const { folder, created } = const { folder, created } =
await createCurrentHostFolderRepository().upsertMetadata( await createCurrentHostFolderRepository().upsertMetadata(
userId, userId,
name, name,
color, color,
icon, icon,
normalizedCredentialId,
); );
if (!created) { if (!created) {
+94 -11
View File
@@ -14,6 +14,7 @@ import {
} from "../../hosts/credential-username.js"; } from "../../hosts/credential-username.js";
import { import {
createCurrentCommandHistoryRepository, createCurrentCommandHistoryRepository,
createCurrentCredentialRepository,
createCurrentFileManagerBookmarkRepository, createCurrentFileManagerBookmarkRepository,
createCurrentOpksshTokenRepository, createCurrentOpksshTokenRepository,
createCurrentRecentActivityRepository, createCurrentRecentActivityRepository,
@@ -1726,9 +1727,16 @@ router.get(
* /host/db/hosts/export: * /host/db/hosts/export:
* get: * get:
* summary: Export all SSH hosts * 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: * tags:
* - SSH * - SSH
* parameters:
* - in: query
* name: share
* required: false
* schema:
* type: string
* description: Set to "1" to export without embedded secrets.
* responses: * responses:
* 200: * 200:
* description: All exported SSH hosts. * description: All exported SSH hosts.
@@ -1743,6 +1751,7 @@ router.get(
requireDataAccess, requireDataAccess,
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
const shareMode = req.query.share === "1" || req.query.share === "true";
if (!isNonEmptyString(userId)) { if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid userId" }); return res.status(400).json({ error: "Invalid userId" });
@@ -1753,10 +1762,12 @@ router.get(
await createCurrentHostResolutionRepository().findHostsByUserId(userId); await createCurrentHostResolutionRepository().findHostsByUserId(userId);
const exportedHosts = []; const exportedHosts = [];
const usedCredentialIds = new Set<number>();
for (const host of allHosts) { for (const host of allHosts) {
const resolvedHost = const resolvedHost = shareMode
(await resolveHostCredentials(host, userId)) || host; ? host
: (await resolveHostCredentials(host, userId)) || host;
const exportedConnectionType = const exportedConnectionType =
(resolvedHost.connectionType as string) || "ssh"; (resolvedHost.connectionType as string) || "ssh";
@@ -1770,7 +1781,7 @@ router.get(
ip: resolvedHost.ip, ip: resolvedHost.ip,
port: resolvedHost.port, port: resolvedHost.port,
username: resolvedHost.username, username: resolvedHost.username,
password: resolvedHost.password || null, password: shareMode ? null : resolvedHost.password || null,
folder: resolvedHost.folder, folder: resolvedHost.folder,
tags: tags:
typeof resolvedHost.tags === "string" typeof resolvedHost.tags === "string"
@@ -1793,8 +1804,8 @@ router.get(
: { : {
...baseExportData, ...baseExportData,
authType: resolvedHost.authType, authType: resolvedHost.authType,
key: resolvedHost.key || null, key: shareMode ? null : resolvedHost.key || null,
keyPassword: resolvedHost.keyPassword || null, keyPassword: shareMode ? null : resolvedHost.keyPassword || null,
keyType: resolvedHost.keyType || null, keyType: resolvedHost.keyType || null,
credentialId: resolvedHost.credentialId || null, credentialId: resolvedHost.credentialId || null,
overrideCredentialUsername: overrideCredentialUsername:
@@ -1811,7 +1822,9 @@ router.get(
showDockerInSidebar: !!resolvedHost.showDockerInSidebar, showDockerInSidebar: !!resolvedHost.showDockerInSidebar,
showServerStatsInSidebar: !!resolvedHost.showServerStatsInSidebar, showServerStatsInSidebar: !!resolvedHost.showServerStatsInSidebar,
defaultPath: resolvedHost.defaultPath, defaultPath: resolvedHost.defaultPath,
sudoPassword: resolvedHost.sudoPassword || null, sudoPassword: shareMode
? null
: resolvedHost.sudoPassword || null,
tunnelConnections: resolvedHost.tunnelConnections tunnelConnections: resolvedHost.tunnelConnections
? JSON.parse(resolvedHost.tunnelConnections as string) ? JSON.parse(resolvedHost.tunnelConnections as string)
: [], : [],
@@ -1839,22 +1852,92 @@ router.get(
socks5Host: resolvedHost.socks5Host || null, socks5Host: resolvedHost.socks5Host || null,
socks5Port: resolvedHost.socks5Port || null, socks5Port: resolvedHost.socks5Port || null,
socks5Username: resolvedHost.socks5Username || null, socks5Username: resolvedHost.socks5Username || null,
socks5Password: resolvedHost.socks5Password || null, socks5Password: shareMode
? null
: resolvedHost.socks5Password || null,
socks5ProxyChain: resolvedHost.socks5ProxyChain socks5ProxyChain: resolvedHost.socks5ProxyChain
? JSON.parse(resolvedHost.socks5ProxyChain as string) ? JSON.parse(resolvedHost.socks5ProxyChain as string)
: null, : null,
}; };
if (
shareMode &&
!isRemoteDesktop &&
resolvedHost.authType === "credential" &&
resolvedHost.credentialId
) {
usedCredentialIds.add(resolvedHost.credentialId as number);
}
exportedHosts.push(exportData); exportedHosts.push(exportData);
} }
sshLogger.success("All hosts exported with decrypted credentials", { if (!shareMode) {
operation: "hosts_export_all", sshLogger.success("All hosts exported with decrypted credentials", {
operation: "hosts_export_all",
count: exportedHosts.length,
userId,
});
return res.json({ hosts: exportedHosts });
}
const exportedCredentials: Record<string, unknown>[] = [];
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<string, unknown>[]) {
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<string, unknown>[]) {
delete host.credentialId;
}
sshLogger.success("All hosts exported for sharing without secrets", {
operation: "hosts_export_all_share",
count: exportedHosts.length, count: exportedHosts.length,
credentialCount: exportedCredentials.length,
userId, userId,
}); });
res.json({ hosts: exportedHosts }); res.json({
version: "1",
exportedAt: new Date().toISOString(),
credentials: exportedCredentials,
hosts: exportedHosts,
});
} catch (err) { } catch (err) {
sshLogger.error("Failed to export all SSH hosts", err, { sshLogger.error("Failed to export all SSH hosts", err, {
operation: "hosts_export_all", operation: "hosts_export_all",
+220
View File
@@ -14,6 +14,7 @@ import {
} from "../../utils/permission-catalog.js"; } from "../../utils/permission-catalog.js";
import { import {
createCurrentCredentialRepository, createCurrentCredentialRepository,
createCurrentHostFolderRepository,
createCurrentHostResolutionRepository, createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository, createCurrentRbacAccessRepository,
createCurrentRoleRepository, 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 * @openapi
* /rbac/host/{id}/access/{accessId}: * /rbac/host/{id}/access/{accessId}:
+53 -27
View File
@@ -122,35 +122,61 @@ export async function resolveHostById(
repository, repository,
); );
if (!resolved) return null; if (!resolved) return null;
} else if (host.credentialId) { } else {
try { let effectiveCredentialId = host.credentialId as number | null | undefined;
const cred = (await repository.findCredentialByIdForUser( if (
host.credentialId as number, !effectiveCredentialId &&
ownerId, host.authType === "credential" &&
)) as Record<string, unknown> | null; host.folder
) {
if (cred) { try {
host.password = pickResolvedPassword(host.password, cred.password); effectiveCredentialId = await repository.findFolderCredentialId(
// Prefer the normalised private key; fall back to raw key field ownerId,
host.key = (cred.privateKey || cred.key) as string | null; host.folder as string,
host.keyPassword = cred.keyPassword;
host.keyType = cred.keyType;
// CA-signed certificate for cert-based auth
(host as Record<string, unknown>).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 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<string, unknown> | 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<string, unknown>).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",
});
} }
} }
+18
View File
@@ -32,6 +32,7 @@ import { collectSystemMetrics } from "./widgets/system-collector.js";
import { collectLoginStats } from "./widgets/login-stats-collector.js"; import { collectLoginStats } from "./widgets/login-stats-collector.js";
import { collectPortsMetrics } from "./widgets/ports-collector.js"; import { collectPortsMetrics } from "./widgets/ports-collector.js";
import { collectFirewallMetrics } from "./widgets/firewall-collector.js"; import { collectFirewallMetrics } from "./widgets/firewall-collector.js";
import { collectTemperatureMetrics } from "./widgets/temperature-collector.js";
import { import {
createSocks5Connection, createSocks5Connection,
type SOCKS5Config, type SOCKS5Config,
@@ -146,6 +147,7 @@ const DEFAULT_STATS_CONFIG: StatsConfig = {
"processes", "processes",
"ports", "ports",
"firewall", "firewall",
"temperature",
], ],
statusCheckEnabled: true, statusCheckEnabled: true,
statusCheckInterval: 60, statusCheckInterval: 60,
@@ -1582,6 +1584,21 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
// expected // 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 = { const result = {
cpu, cpu,
memory, memory,
@@ -1593,6 +1610,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
login_stats, login_stats,
ports, ports,
firewall, firewall,
temperature,
}; };
metricsCache.set(host.id, result); metricsCache.set(host.id, result);
@@ -139,6 +139,7 @@ describe("HostFolderRepository", () => {
name TEXT NOT NULL, name TEXT NOT NULL,
color TEXT, color TEXT,
icon TEXT, icon TEXT,
credential_id INTEGER,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -216,6 +217,7 @@ describe("HostFolderRepository", () => {
"prod", "prod",
"#abcdef", "#abcdef",
"folder", "folder",
undefined,
"2026-02-01T00:00:00.000Z", "2026-02-01T00:00:00.000Z",
), ),
).resolves.toMatchObject({ ).resolves.toMatchObject({
@@ -228,6 +230,7 @@ describe("HostFolderRepository", () => {
"new", "new",
null, null,
null, null,
null,
"2026-03-01T00:00:00.000Z", "2026-03-01T00:00:00.000Z",
), ),
).resolves.toMatchObject({ ).resolves.toMatchObject({
@@ -237,6 +240,28 @@ describe("HostFolderRepository", () => {
expect(writes).toBe(2); 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 () => { it("lists and deletes hosts and folder records in a folder tree", async () => {
let writes = 0; let writes = 0;
const { repository, sqlite } = await createRepository(() => { const { repository, sqlite } = await createRepository(() => {
@@ -165,6 +165,17 @@ describe("HostResolutionRepository", () => {
override_credential_id INTEGER 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) INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_data ( INSERT INTO ssh_data (
@@ -185,6 +196,11 @@ describe("HostResolutionRepository", () => {
host_id, user_id, granted_by, permission_level, override_credential_id host_id, user_id, granted_by, permission_level, override_credential_id
) )
VALUES (1, 'user-2', 'user-1', 'execute', 8); 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); return new HostResolutionRepository(context, onWrite);
@@ -492,4 +508,24 @@ describe("HostResolutionRepository", () => {
repository.findOverrideCredentialId(1, "user-1"), repository.findOverrideCredentialId(1, "user-1"),
).resolves.toBeNull(); ).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();
});
}); });
@@ -8,6 +8,7 @@ const state = vi.hoisted(() => ({
credentials: new Map<string, Record<string, unknown>>(), credentials: new Map<string, Record<string, unknown>>(),
sharedSecret: null as Record<string, unknown> | null, sharedSecret: null as Record<string, unknown> | null,
auditCalls: [] as Record<string, unknown>[], auditCalls: [] as Record<string, unknown>[],
folderCredentialId: null as number | null,
})); }));
vi.mock("../../database/repositories/factory.js", () => ({ vi.mock("../../database/repositories/factory.js", () => ({
@@ -17,6 +18,7 @@ vi.mock("../../database/repositories/factory.js", () => ({
findOverrideCredentialId: async () => state.overrideCredentialId, findOverrideCredentialId: async () => state.overrideCredentialId,
findCredentialByIdForUser: async (credentialId: number, userId: string) => findCredentialByIdForUser: async (credentialId: number, userId: string) =>
state.credentials.get(`${credentialId}:${userId}`) ?? null, state.credentials.get(`${credentialId}:${userId}`) ?? null,
findFolderCredentialId: async () => state.folderCredentialId,
}), }),
createCurrentVaultProfileRepository: () => ({ createCurrentVaultProfileRepository: () => ({
findById: async () => null, findById: async () => null,
@@ -101,6 +103,7 @@ beforeEach(() => {
state.credentials.clear(); state.credentials.clear();
state.sharedSecret = null; state.sharedSecret = null;
state.auditCalls = []; state.auditCalls = [];
state.folderCredentialId = null;
}); });
describe("resolveHostById", () => { describe("resolveHostById", () => {
@@ -138,6 +141,63 @@ describe("resolveHostById", () => {
expect(host.sudoPassword).toBe("owner-sudo"); 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 () => { it("uses the share snapshot for a non-owner and strips owner-only secrets", async () => {
state.host = baseHost({ username: "" }); state.host = baseHost({ username: "" });
state.sharedSecret = { state.sharedSecret = {
+1
View File
@@ -343,6 +343,7 @@ export interface SSHFolder {
name: string; name: string;
color?: string; color?: string;
icon?: string; icon?: string;
credentialId?: number | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
+1
View File
@@ -221,6 +221,7 @@ export type HostFolder = {
path?: string; path?: string;
color?: string; color?: string;
icon?: string; icon?: string;
credentialId?: number | null;
}; };
export type TabType = export type TabType =
+10 -2
View File
@@ -141,7 +141,10 @@ import { quickConnectHostToPayload } from "@/sidebar/quick-connect-host";
function buildHostTree( function buildHostTree(
hosts: SSHHostWithStatus[], hosts: SSHHostWithStatus[],
folderMeta?: Map<string, { color?: string; icon?: string }>, folderMeta?: Map<
string,
{ color?: string; icon?: string; credentialId?: number | null }
>,
): HostFolder { ): HostFolder {
const root: HostFolder = { name: "root", children: [] }; const root: HostFolder = { name: "root", children: [] };
const folderMap = new Map<string, HostFolder>(); const folderMap = new Map<string, HostFolder>();
@@ -159,6 +162,7 @@ function buildHostTree(
path: accumulated, path: accumulated,
color: meta?.color, color: meta?.color,
icon: meta?.icon, icon: meta?.icon,
credentialId: meta?.credentialId ?? null,
children: [], children: [],
}; };
folderMap.set(accumulated, folder); folderMap.set(accumulated, folder);
@@ -798,11 +802,15 @@ export function AppShell({
]); ]);
const converted = raw.map(sshHostToHost); const converted = raw.map(sshHostToHost);
setAllHosts(converted); setAllHosts(converted);
const folderMeta = new Map<string, { color?: string; icon?: string }>(); const folderMeta = new Map<
string,
{ color?: string; icon?: string; credentialId?: number | null }
>();
for (const f of folders) { for (const f of folders) {
folderMeta.set(f.name, { folderMeta.set(f.name, {
color: f.color ?? undefined, color: f.color ?? undefined,
icon: f.icon ?? undefined, icon: f.icon ?? undefined,
credentialId: f.credentialId ?? null,
}); });
} }
setRealHostTree(buildHostTree(raw, folderMeta)); setRealHostTree(buildHostTree(raw, folderMeta));
+3
View File
@@ -200,6 +200,7 @@ export async function updateFolderMetadata(
name: string, name: string,
color?: string, color?: string,
icon?: string, icon?: string,
credentialId?: number | null,
): Promise<void> { ): Promise<void> {
try { try {
sshLogger.info("Updating folder metadata", { sshLogger.info("Updating folder metadata", {
@@ -207,12 +208,14 @@ export async function updateFolderMetadata(
name, name,
color, color,
icon, icon,
credentialId,
}); });
await authApi.put("/host/folders/metadata", { await authApi.put("/host/folders/metadata", {
name, name,
color, color,
icon, icon,
credentialId,
}); });
sshLogger.success("Folder metadata updated successfully", { sshLogger.success("Folder metadata updated successfully", {
+25
View File
@@ -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( export async function updateHostAccess(
hostId: number, hostId: number,
accessId: number, accessId: number,
+8
View File
@@ -1002,6 +1002,9 @@
"folderNestingHint": "Use / to separate levels and create nested folders.", "folderNestingHint": "Use / to separate levels and create nested folders.",
"folderColor": "Color", "folderColor": "Color",
"folderIcon": "Icon", "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", "folderPreview": "Preview",
"folderNameFallback": "Untitled folder", "folderNameFallback": "Untitled folder",
"createFolderButton": "Create folder", "createFolderButton": "Create folder",
@@ -1183,6 +1186,10 @@
"filterTagsGroup": "Tags", "filterTagsGroup": "Tags",
"shareHost": "Share Host", "shareHost": "Share Host",
"shareHostTitle": "Share: {{name}}", "shareHostTitle": "Share: {{name}}",
"shareFolder": "Share Folder",
"shareFolderTitle": "Share folder: {{name}}",
"folderSharedSuccessfully": "Shared {{count}} host(s) in folder",
"failedToShareFolder": "Failed to share folder",
"sharing": { "sharing": {
"loadError": "Failed to load sharing data. Please try again.", "loadError": "Failed to load sharing data. Please try again.",
"shareWithSection": "Share with", "shareWithSection": "Share with",
@@ -1223,6 +1230,7 @@
"shareWithCount": "Share ({{count}})", "shareWithCount": "Share ({{count}})",
"currentAccess": "Current access", "currentAccess": "Current access",
"noAccessEntries": "This host has not been shared yet", "noAccessEntries": "This host has not been shared yet",
"folderShareSummary": "Shared {{shared}} of {{total}} host(s) in this folder",
"grantedBy": "Granted by", "grantedBy": "Granted by",
"expires": "Expires", "expires": "Expires",
"expired": "Expired", "expired": "Expired",
+1
View File
@@ -2120,6 +2120,7 @@ export {
assignRoleToUser, assignRoleToUser,
removeRoleFromUser, removeRoleFromUser,
shareHost, shareHost,
shareFolder,
updateHostAccess, updateHostAccess,
getHostAccess, getHostAccess,
revokeHostAccess, revokeHostAccess,
+18 -13
View File
@@ -110,8 +110,8 @@ export function AdminSettingsPanel({
onOpenHostTab?: (host: Host) => void; onOpenHostTab?: (host: Host) => void;
} = {}) { } = {}) {
const { t } = useTranslation(); const { t } = useTranslation();
const [openSection, setOpenSection] = useState<AdminSection | null>( const [openSections, setOpenSections] = useState<Set<AdminSection>>(
"general", () => new Set(["general"]),
); );
const [manageUser, setManageUser] = useState<AdminUser | null>(null); const [manageUser, setManageUser] = useState<AdminUser | null>(null);
const [allowRegistration, setAllowRegistration] = useState(true); const [allowRegistration, setAllowRegistration] = useState(true);
@@ -347,7 +347,12 @@ export function AdminSettingsPanel({
} }
function toggle(id: AdminSection) { 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() { async function handleSaveHostDefaults() {
@@ -854,7 +859,7 @@ export function AdminSettingsPanel({
return ( return (
<div className="flex flex-col gap-2 p-3 flex-1 min-h-0 overflow-y-auto"> <div className="flex flex-col gap-2 p-3 flex-1 min-h-0 overflow-y-auto">
<AdminGeneralSettingsSection <AdminGeneralSettingsSection
open={openSection === "general"} open={openSections.has("general")}
onToggle={() => toggle("general")} onToggle={() => toggle("general")}
allowRegistration={allowRegistration} allowRegistration={allowRegistration}
handleToggleRegistration={handleToggleRegistration} handleToggleRegistration={handleToggleRegistration}
@@ -891,7 +896,7 @@ export function AdminSettingsPanel({
/> />
<AdminSSOSection <AdminSSOSection
open={openSection === "sso"} open={openSections.has("sso")}
onToggle={() => toggle("sso")} onToggle={() => toggle("sso")}
providers={ssoProviders} providers={ssoProviders}
onAddProvider={handleAddProvider} onAddProvider={handleAddProvider}
@@ -908,7 +913,7 @@ export function AdminSettingsPanel({
/> />
<AdminUsersSection <AdminUsersSection
open={openSection === "users"} open={openSections.has("users")}
onToggle={() => toggle("users")} onToggle={() => toggle("users")}
users={users} users={users}
setUsers={setUsers} setUsers={setUsers}
@@ -924,7 +929,7 @@ export function AdminSettingsPanel({
/> />
<AdminSessionsSection <AdminSessionsSection
open={openSection === "sessions"} open={openSections.has("sessions")}
onToggle={() => toggle("sessions")} onToggle={() => toggle("sessions")}
sessions={sessions} sessions={sessions}
setSessions={setSessions} setSessions={setSessions}
@@ -932,7 +937,7 @@ export function AdminSettingsPanel({
/> />
<AdminRolesSection <AdminRolesSection
open={openSection === "roles"} open={openSections.has("roles")}
onToggle={() => toggle("roles")} onToggle={() => toggle("roles")}
roles={roles} roles={roles}
setRoles={setRoles} setRoles={setRoles}
@@ -949,7 +954,7 @@ export function AdminSettingsPanel({
/> />
<AdminHostDefaultsSection <AdminHostDefaultsSection
open={openSection === "host-defaults"} open={openSections.has("host-defaults")}
onToggle={() => toggle("host-defaults")} onToggle={() => toggle("host-defaults")}
defaults={hostDefaults} defaults={hostDefaults}
setDefaults={setHostDefaults} setDefaults={setHostDefaults}
@@ -957,7 +962,7 @@ export function AdminSettingsPanel({
/> />
<AdminDatabaseSection <AdminDatabaseSection
open={openSection === "database"} open={openSections.has("database")}
onToggle={() => toggle("database")} onToggle={() => toggle("database")}
importFile={importFile} importFile={importFile}
setImportFile={setImportFile} setImportFile={setImportFile}
@@ -968,7 +973,7 @@ export function AdminSettingsPanel({
/> />
<AdminSSLSection <AdminSSLSection
open={openSection === "ssl"} open={openSections.has("ssl")}
onToggle={() => toggle("ssl")} onToggle={() => toggle("ssl")}
settings={acmeSettings} settings={acmeSettings}
setSettings={setAcmeSettings} setSettings={setAcmeSettings}
@@ -980,7 +985,7 @@ export function AdminSettingsPanel({
/> />
<AdminApiKeysSection <AdminApiKeysSection
open={openSection === "api-keys"} open={openSections.has("api-keys")}
onToggle={() => toggle("api-keys")} onToggle={() => toggle("api-keys")}
apiKeys={apiKeys} apiKeys={apiKeys}
setApiKeys={setApiKeys} setApiKeys={setApiKeys}
@@ -1001,7 +1006,7 @@ export function AdminSettingsPanel({
/> />
<AdminAuditLogSection <AdminAuditLogSection
open={openSection === "audit-log"} open={openSections.has("audit-log")}
onToggle={() => toggle("audit-log")} onToggle={() => toggle("audit-log")}
users={users} users={users}
/> />
+57 -2
View File
@@ -18,13 +18,17 @@ import {
IconPicker, IconPicker,
} from "@/components/folder-style"; } from "@/components/folder-style";
import { normalizePath, splitPath } from "./FolderPathPicker"; import { normalizePath, splitPath } from "./FolderPathPicker";
import { getCredentials } from "@/main-axios";
export type FolderMetadataValue = { export type FolderMetadataValue = {
name: string; name: string;
color: string; color: string;
icon: string; icon: string;
credentialId: number | null;
}; };
type CredentialOption = { id: string; name: string; username?: string };
export function FolderMetadataDialog({ export function FolderMetadataDialog({
open, open,
mode, mode,
@@ -34,7 +38,12 @@ export function FolderMetadataDialog({
}: { }: {
open: boolean; open: boolean;
mode: "create" | "edit"; mode: "create" | "edit";
initial?: { name: string; color?: string; icon?: string }; initial?: {
name: string;
color?: string;
icon?: string;
credentialId?: number | null;
};
onOpenChange: (v: boolean) => void; onOpenChange: (v: boolean) => void;
onSubmit: (value: FolderMetadataValue) => void; onSubmit: (value: FolderMetadataValue) => void;
}) { }) {
@@ -42,19 +51,45 @@ export function FolderMetadataDialog({
const [name, setName] = useState(""); const [name, setName] = useState("");
const [color, setColor] = useState(DEFAULT_FOLDER_COLOR); const [color, setColor] = useState(DEFAULT_FOLDER_COLOR);
const [icon, setIcon] = useState(DEFAULT_FOLDER_ICON); const [icon, setIcon] = useState(DEFAULT_FOLDER_ICON);
const [credentialId, setCredentialId] = useState<string>("");
const [credentials, setCredentials] = useState<CredentialOption[]>([]);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setName(initial?.name ?? ""); setName(initial?.name ?? "");
setColor(initial?.color ?? DEFAULT_FOLDER_COLOR); setColor(initial?.color ?? DEFAULT_FOLDER_COLOR);
setIcon(initial?.icon ?? DEFAULT_FOLDER_ICON); setIcon(initial?.icon ?? DEFAULT_FOLDER_ICON);
setCredentialId(
initial?.credentialId ? String(initial.credentialId) : "",
);
} }
}, [open, initial]); }, [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() { function handleSubmit() {
const normalized = normalizePath(name); const normalized = normalizePath(name);
if (!normalized) return; if (!normalized) return;
onSubmit({ name: normalized, color, icon }); onSubmit({
name: normalized,
color,
icon,
credentialId: credentialId ? Number(credentialId) : null,
});
onOpenChange(false); onOpenChange(false);
} }
@@ -101,6 +136,26 @@ export function FolderMetadataDialog({
</label> </label>
<IconPicker value={icon} color={color} onChange={setIcon} /> <IconPicker value={icon} color={color} onChange={setIcon} />
</div> </div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderCredential")}
</label>
<select
value={credentialId}
onChange={(e) => setCredentialId(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.folderCredentialNone")}</option>
{credentials.map((c) => (
<option key={c.id} value={c.id}>
{c.username ? `${c.name} (${c.username})` : c.name}
</option>
))}
</select>
<p className="text-[10px] text-muted-foreground">
{t("hosts.folderCredentialHint")}
</p>
</div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold"> <label className="text-xs font-semibold">
{t("hosts.folderPreview")} {t("hosts.folderPreview")}
+178 -129
View File
@@ -23,6 +23,7 @@ import {
import { import {
getHostAccess, getHostAccess,
shareHost, shareHost,
shareFolder,
updateHostAccess, updateHostAccess,
revokeHostAccess, revokeHostAccess,
getUserList, getUserList,
@@ -55,12 +56,15 @@ export function HostShareModal({
open, open,
onClose, onClose,
host, host,
folder,
}: { }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
host: Host | null; host: Host | null;
folder?: string | null;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const isFolderShare = !host && !!folder;
const [targetTab, setTargetTab] = useState<"user" | "role">("user"); const [targetTab, setTargetTab] = useState<"user" | "role">("user");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>( const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(
@@ -83,13 +87,19 @@ export function HostShareModal({
const [sharingLoaded, setSharingLoaded] = useState(false); const [sharingLoaded, setSharingLoaded] = useState(false);
const [sharingLoadError, setSharingLoadError] = useState(false); const [sharingLoadError, setSharingLoadError] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [folderShareSummary, setFolderShareSummary] = useState<{
hostsShared: number;
hostsTotal: number;
} | null>(null);
useEffect(() => { useEffect(() => {
if (!open || !host) return; if (!open || (!host && !folder)) return;
if (sharingLoaded) return; if (sharingLoaded) return;
setSharingLoaded(true); setSharingLoaded(true);
Promise.all([ Promise.all([
getHostAccess(Number(host.id)).catch(() => ({ accessList: [] })), host
? getHostAccess(Number(host.id)).catch(() => ({ accessList: [] }))
: Promise.resolve({ accessList: [] }),
getUserList().catch(() => ({ users: [] })), getUserList().catch(() => ({ users: [] })),
getRoles().catch(() => ({ roles: [] })), getRoles().catch(() => ({ roles: [] })),
]) ])
@@ -112,7 +122,7 @@ export function HostShareModal({
); );
}) })
.catch(() => setSharingLoadError(true)); .catch(() => setSharingLoadError(true));
}, [open, host, sharingLoaded]); }, [open, host, folder, sharingLoaded]);
useEffect(() => { useEffect(() => {
setSharingLoaded(false); setSharingLoaded(false);
@@ -125,7 +135,8 @@ export function HostShareModal({
setExpiryPreset("never"); setExpiryPreset("never");
setCustomHours(""); setCustomHours("");
setTargetTab("user"); setTargetTab("user");
}, [host?.id]); setFolderShareSummary(null);
}, [host?.id, folder]);
const filteredUsers = useMemo(() => { const filteredUsers = useMemo(() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
@@ -165,7 +176,7 @@ export function HostShareModal({
} }
async function handleShare() { async function handleShare() {
if (!host || selectedCount === 0) return; if ((!host && !folder) || selectedCount === 0) return;
const targets: ShareTarget[] = [ const targets: ShareTarget[] = [
...[...selectedUserIds].map( ...[...selectedUserIds].map(
(id) => ({ type: "user", id }) as ShareTarget, (id) => ({ type: "user", id }) as ShareTarget,
@@ -177,17 +188,40 @@ export function HostShareModal({
setSubmitting(true); setSubmitting(true);
try { try {
await shareHost(Number(host.id), { if (isFolderShare && folder) {
targets, const result = await shareFolder(folder, {
permissionLevel, targets,
...(durationHours ? { durationHours } : {}), permissionLevel,
}); ...(durationHours ? { durationHours } : {}),
await refreshAccessList(); });
setSelectedUserIds(new Set()); setFolderShareSummary({
setSelectedRoleIds(new Set()); hostsShared: result.hostsShared,
toast.success(t("hosts.hostSharedSuccessfully")); hostsTotal: result.hostsTotal,
});
setSelectedUserIds(new Set());
setSelectedRoleIds(new Set());
toast.success(
t("hosts.folderSharedSuccessfully", {
count: result.hostsShared,
}),
);
} else if (host) {
await shareHost(Number(host.id), {
targets,
permissionLevel,
...(durationHours ? { durationHours } : {}),
});
await refreshAccessList();
setSelectedUserIds(new Set());
setSelectedRoleIds(new Set());
toast.success(t("hosts.hostSharedSuccessfully"));
}
} catch { } catch {
toast.error(t("hosts.failedToShareHost")); toast.error(
isFolderShare
? t("hosts.failedToShareFolder")
: t("hosts.failedToShareHost"),
);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -224,7 +258,9 @@ export function HostShareModal({
> >
<ArrowLeft className="size-3.5 shrink-0" /> <ArrowLeft className="size-3.5 shrink-0" />
<span className="truncate"> <span className="truncate">
{t("hosts.shareHostTitle", { name: host?.name ?? "" })} {isFolderShare
? t("hosts.shareFolderTitle", { name: folder ?? "" })
: t("hosts.shareHostTitle", { name: host?.name ?? "" })}
</span> </span>
</button> </button>
@@ -441,121 +477,134 @@ export function HostShareModal({
)} )}
</div> </div>
{/* Current access: takes remaining space, scrolls independently */} {/* Folder share summary */}
<div className="flex flex-col flex-1 min-h-0"> {isFolderShare && folderShareSummary && (
<div className="flex items-center gap-1.5 px-3 py-2 shrink-0 border-b border-border text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"> <div className="flex items-center gap-1.5 px-3 py-2 shrink-0 text-xs text-muted-foreground">
<ListChecks className="size-3.5" /> <ListChecks className="size-3.5 shrink-0" />
{t("hosts.sharing.currentAccess")} {t("hosts.sharing.folderShareSummary", {
{accessList.length > 0 && ( shared: folderShareSummary.hostsShared,
<span className="text-muted-foreground/40"> total: folderShareSummary.hostsTotal,
({accessList.length})
</span>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{accessList.length === 0 && (
<div className="px-3 py-6 text-xs text-muted-foreground/50 text-center">
{t("hosts.sharing.noAccessEntries")}
</div>
)}
{accessList.map((record) => {
const expired =
record.expiresAt && new Date(record.expiresAt) < new Date();
return (
<div
key={record.id}
className="flex flex-col gap-1 px-3 py-2 border-b border-border/60 last:border-0 text-xs"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
{record.targetType === "user" ? (
<User className="size-3 text-muted-foreground shrink-0" />
) : (
<Shield className="size-3 text-muted-foreground shrink-0" />
)}
<span className="font-semibold truncate">
{record.username ??
record.roleDisplayName ??
record.roleName ??
record.userId ??
record.roleId}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-widest border border-accent-brand/30 bg-accent-brand/10 text-accent-brand transition-colors hover:bg-accent-brand/20">
{t(
`hosts.sharing.levels.${record.permissionLevel ?? "connect"}.label`,
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs">
{PERMISSION_LEVELS.map((level) => (
<DropdownMenuItem
key={level}
onClick={() => handleLevelChange(record, level)}
>
{record.permissionLevel === level ? (
<Check className="size-3 mr-1.5" />
) : (
<span className="size-3 mr-1.5" />
)}
{t(`hosts.sharing.levels.${level}.label`)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10"
onClick={async () => {
try {
await revokeHostAccess(Number(host!.id), record.id);
setAccessList((prev) =>
prev.filter((entry) => entry.id !== record.id),
);
toast.success(t("hosts.accessRevoked"));
} catch {
toast.error(t("hosts.failedToRevokeAccess"));
}
}}
>
{t("hosts.sharing.revoke")}
</Button>
</div>
</div>
<div className="flex items-center gap-3 text-[10px] text-muted-foreground pl-4">
<span>
{t("hosts.sharing.grantedBy")}:{" "}
<span className="text-foreground/70">
{record.grantedByUsername ?? "?"}
</span>
</span>
<span className={expired ? "text-destructive" : ""}>
{t("hosts.sharing.expires")}:{" "}
{expired ? (
<span className="inline-flex items-center gap-0.5 text-destructive">
<X className="size-3" />
{t("hosts.sharing.expired")}
</span>
) : record.expiresAt ? (
<span className="text-foreground/70">
{new Date(record.expiresAt).toLocaleString()}
</span>
) : (
<span className="text-foreground/70">
{t("hosts.sharing.never")}
</span>
)}
</span>
</div>
</div>
);
})} })}
</div> </div>
</div> )}
{/* Current access: takes remaining space, scrolls independently */}
{!isFolderShare && (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex items-center gap-1.5 px-3 py-2 shrink-0 border-b border-border text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<ListChecks className="size-3.5" />
{t("hosts.sharing.currentAccess")}
{accessList.length > 0 && (
<span className="text-muted-foreground/40">
({accessList.length})
</span>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{accessList.length === 0 && (
<div className="px-3 py-6 text-xs text-muted-foreground/50 text-center">
{t("hosts.sharing.noAccessEntries")}
</div>
)}
{accessList.map((record) => {
const expired =
record.expiresAt && new Date(record.expiresAt) < new Date();
return (
<div
key={record.id}
className="flex flex-col gap-1 px-3 py-2 border-b border-border/60 last:border-0 text-xs"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
{record.targetType === "user" ? (
<User className="size-3 text-muted-foreground shrink-0" />
) : (
<Shield className="size-3 text-muted-foreground shrink-0" />
)}
<span className="font-semibold truncate">
{record.username ??
record.roleDisplayName ??
record.roleName ??
record.userId ??
record.roleId}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-widest border border-accent-brand/30 bg-accent-brand/10 text-accent-brand transition-colors hover:bg-accent-brand/20">
{t(
`hosts.sharing.levels.${record.permissionLevel ?? "connect"}.label`,
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs">
{PERMISSION_LEVELS.map((level) => (
<DropdownMenuItem
key={level}
onClick={() => handleLevelChange(record, level)}
>
{record.permissionLevel === level ? (
<Check className="size-3 mr-1.5" />
) : (
<span className="size-3 mr-1.5" />
)}
{t(`hosts.sharing.levels.${level}.label`)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10"
onClick={async () => {
try {
await revokeHostAccess(Number(host!.id), record.id);
setAccessList((prev) =>
prev.filter((entry) => entry.id !== record.id),
);
toast.success(t("hosts.accessRevoked"));
} catch {
toast.error(t("hosts.failedToRevokeAccess"));
}
}}
>
{t("hosts.sharing.revoke")}
</Button>
</div>
</div>
<div className="flex items-center gap-3 text-[10px] text-muted-foreground pl-4">
<span>
{t("hosts.sharing.grantedBy")}:{" "}
<span className="text-foreground/70">
{record.grantedByUsername ?? "?"}
</span>
</span>
<span className={expired ? "text-destructive" : ""}>
{t("hosts.sharing.expires")}:{" "}
{expired ? (
<span className="inline-flex items-center gap-0.5 text-destructive">
<X className="size-3" />
{t("hosts.sharing.expired")}
</span>
) : record.expiresAt ? (
<span className="text-foreground/70">
{new Date(record.expiresAt).toLocaleString()}
</span>
) : (
<span className="text-foreground/70">
{t("hosts.sharing.never")}
</span>
)}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
</div> </div>
); );
} }
+47 -6
View File
@@ -71,6 +71,7 @@ import {
canShareHost, canShareHost,
} from "@/sidebar/host-permissions"; } from "@/sidebar/host-permissions";
import { FolderMetadataDialog } from "./FolderMetadataDialog"; import { FolderMetadataDialog } from "./FolderMetadataDialog";
import { HostShareModal } from "@/sidebar/HostShareModal";
import { import {
useStatusColorScheme, useStatusColorScheme,
getStatusClasses, getStatusClasses,
@@ -319,7 +320,7 @@ export function HostItem({
const metricsEnabled = const metricsEnabled =
host.enableSsh && host.statsConfig?.metricsEnabled !== false; host.enableSsh && host.statsConfig?.metricsEnabled !== false;
const [trayOnClick, setTrayOnClick] = useState( const [trayOnClick, setTrayOnClick] = useState(
() => localStorage.getItem("hostTrayOnClick") === "true", () => localStorage.getItem("hostTrayOnClick") !== "false",
); );
const [showHostTags, setShowHostTags] = useState<boolean>(() => { const [showHostTags, setShowHostTags] = useState<boolean>(() => {
const v = localStorage.getItem("showHostTags"); const v = localStorage.getItem("showHostTags");
@@ -362,7 +363,7 @@ export function HostItem({
useEffect(() => { useEffect(() => {
const handler = () => const handler = () =>
setTrayOnClick(localStorage.getItem("hostTrayOnClick") === "true"); setTrayOnClick(localStorage.getItem("hostTrayOnClick") !== "false");
window.addEventListener("storage", handler); window.addEventListener("storage", handler);
window.addEventListener("hostTrayOnClickChanged", handler); window.addEventListener("hostTrayOnClickChanged", handler);
return () => { return () => {
@@ -1561,6 +1562,7 @@ export function FolderItem({
onManageFolder, onManageFolder,
onDeleteFolder, onDeleteFolder,
onOpenAllSessions, onOpenAllSessions,
onShareFolder,
onMoveHostsToFolder, onMoveHostsToFolder,
draggedHostIds, draggedHostIds,
onDragHostStart, onDragHostStart,
@@ -1591,6 +1593,7 @@ export function FolderItem({
onManageFolder: (folder: HostFolder) => void; onManageFolder: (folder: HostFolder) => void;
onDeleteFolder: (folder: HostFolder) => void; onDeleteFolder: (folder: HostFolder) => void;
onOpenAllSessions: (folder: HostFolder) => void; onOpenAllSessions: (folder: HostFolder) => void;
onShareFolder?: (folder: HostFolder) => void;
onMoveHostsToFolder: (hostIds: string[], targetPath: string) => void; onMoveHostsToFolder: (hostIds: string[], targetPath: string) => void;
draggedHostIds: string[] | null; draggedHostIds: string[] | null;
onDragHostStart: (hostId: string) => void; onDragHostStart: (hostId: string) => void;
@@ -1674,6 +1677,18 @@ export function FolderItem({
> >
<FolderOpen className="size-2.5" /> <FolderOpen className="size-2.5" />
</span> </span>
{onShareFolder && (
<span
title={t("hosts.shareFolder")}
className="text-muted-foreground/50 hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onShareFolder(folder);
}}
>
<Share2 className="size-2.5" />
</span>
)}
<span <span
title={t("hosts.editFolder")} title={t("hosts.editFolder")}
className="text-muted-foreground/50 hover:text-foreground" className="text-muted-foreground/50 hover:text-foreground"
@@ -1727,6 +1742,7 @@ export function FolderItem({
onManageFolder={onManageFolder} onManageFolder={onManageFolder}
onDeleteFolder={onDeleteFolder} onDeleteFolder={onDeleteFolder}
onOpenAllSessions={onOpenAllSessions} onOpenAllSessions={onOpenAllSessions}
onShareFolder={onShareFolder}
onMoveHostsToFolder={onMoveHostsToFolder} onMoveHostsToFolder={onMoveHostsToFolder}
draggedHostIds={draggedHostIds} draggedHostIds={draggedHostIds}
onDragHostStart={onDragHostStart} onDragHostStart={onDragHostStart}
@@ -1813,11 +1829,14 @@ export function SidebarTree({
mode: "create" | "edit"; mode: "create" | "edit";
folder?: HostFolder; folder?: HostFolder;
} | null>(null); } | null>(null);
const [shareFolderTarget, setShareFolderTarget] = useState<string | null>(
null,
);
const [compactHostView, setCompactHostView] = useState( const [compactHostView, setCompactHostView] = useState(
() => localStorage.getItem("compactHostView") === "true", () => localStorage.getItem("compactHostView") === "true",
); );
const [trayOnClick, setTrayOnClick] = useState( const [trayOnClick, setTrayOnClick] = useState(
() => localStorage.getItem("hostTrayOnClick") === "true", () => localStorage.getItem("hostTrayOnClick") !== "false",
); );
useEffect(() => { useEffect(() => {
@@ -1833,7 +1852,7 @@ export function SidebarTree({
useEffect(() => { useEffect(() => {
const handler = () => const handler = () =>
setTrayOnClick(localStorage.getItem("hostTrayOnClick") === "true"); setTrayOnClick(localStorage.getItem("hostTrayOnClick") !== "false");
window.addEventListener("storage", handler); window.addEventListener("storage", handler);
window.addEventListener("hostTrayOnClickChanged", handler); window.addEventListener("hostTrayOnClickChanged", handler);
return () => { return () => {
@@ -1886,6 +1905,7 @@ export function SidebarTree({
name: string; name: string;
color: string; color: string;
icon: string; icon: string;
credentialId: number | null;
}) { }) {
const existing = folderDialog?.folder; const existing = folderDialog?.folder;
try { try {
@@ -1898,9 +1918,19 @@ export function SidebarTree({
if (newPath !== oldPath) { if (newPath !== oldPath) {
await renameFolder(oldPath, newPath); await renameFolder(oldPath, newPath);
} }
await updateFolderMetadata(newPath, value.color, value.icon); await updateFolderMetadata(
newPath,
value.color,
value.icon,
value.credentialId,
);
} else { } 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")); window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
toast.success(t("hosts.folderSaved")); toast.success(t("hosts.folderSaved"));
@@ -2224,6 +2254,9 @@ export function SidebarTree({
onManageFolder={handleManageFolder} onManageFolder={handleManageFolder}
onDeleteFolder={handleDeleteFolder} onDeleteFolder={handleDeleteFolder}
onOpenAllSessions={handleOpenAllSessions} onOpenAllSessions={handleOpenAllSessions}
onShareFolder={(folder) =>
setShareFolderTarget(folder.path ?? folder.name)
}
onMoveHostsToFolder={handleMoveHostsToFolder} onMoveHostsToFolder={handleMoveHostsToFolder}
draggedHostIds={draggedHostIds} draggedHostIds={draggedHostIds}
onDragHostStart={handleDragHostStart} onDragHostStart={handleDragHostStart}
@@ -2541,12 +2574,20 @@ export function SidebarTree({
name: folderDialog.folder.name, name: folderDialog.folder.name,
color: folderDialog.folder.color, color: folderDialog.folder.color,
icon: folderDialog.folder.icon, icon: folderDialog.folder.icon,
credentialId: folderDialog.folder.credentialId,
} }
: undefined : undefined
} }
onOpenChange={(v) => !v && setFolderDialog(null)} onOpenChange={(v) => !v && setFolderDialog(null)}
onSubmit={handleSaveFolderMetadata} onSubmit={handleSaveFolderMetadata}
/> />
<HostShareModal
open={shareFolderTarget !== null}
onClose={() => setShareFolderTarget(null)}
host={null}
folder={shareFolderTarget}
/>
</div> </div>
); );
} }
+18 -13
View File
@@ -464,8 +464,8 @@ export function UserProfilePanel({
"one-dark": t("newUi.sidebar.userProfile.themeOneDark"), "one-dark": t("newUi.sidebar.userProfile.themeOneDark"),
gruvbox: t("newUi.sidebar.userProfile.themeGruvbox"), gruvbox: t("newUi.sidebar.userProfile.themeGruvbox"),
}; };
const [openSection, setOpenSection] = useState<UserProfileSection | null>( const [openSections, setOpenSections] = useState<Set<UserProfileSection>>(
"account", () => new Set(["account"]),
); );
// User info // User info
@@ -548,7 +548,7 @@ export function UserProfilePanel({
return v !== null ? v === "true" : true; return v !== null ? v === "true" : true;
}); });
const [hostTrayOnClick, setHostTrayOnClick] = useState( const [hostTrayOnClick, setHostTrayOnClick] = useState(
() => localStorage.getItem("hostTrayOnClick") === "true", () => localStorage.getItem("hostTrayOnClick") !== "false",
); );
const [compactHostView, setCompactHostView] = useState( const [compactHostView, setCompactHostView] = useState(
() => localStorage.getItem("compactHostView") === "true", () => localStorage.getItem("compactHostView") === "true",
@@ -790,8 +790,8 @@ export function UserProfilePanel({
setShowHostTags(true); setShowHostTags(true);
localStorage.setItem("showHostTags", "true"); localStorage.setItem("showHostTags", "true");
window.dispatchEvent(new CustomEvent("showHostTagsChanged")); window.dispatchEvent(new CustomEvent("showHostTagsChanged"));
setHostTrayOnClick(false); setHostTrayOnClick(true);
localStorage.setItem("hostTrayOnClick", "false"); localStorage.setItem("hostTrayOnClick", "true");
setCompactHostView(false); setCompactHostView(false);
localStorage.setItem("compactHostView", "false"); localStorage.setItem("compactHostView", "false");
window.dispatchEvent(new CustomEvent("compactHostViewChanged")); window.dispatchEvent(new CustomEvent("compactHostViewChanged"));
@@ -824,7 +824,7 @@ export function UserProfilePanel({
commandAutocomplete: false, commandAutocomplete: false,
commandPaletteEnabled: true, commandPaletteEnabled: true,
showHostTags: true, showHostTags: true,
hostTrayOnClick: false, hostTrayOnClick: true,
compactHostView: false, compactHostView: false,
pinAppRail: false, pinAppRail: false,
expandAppRailOnHover: true, expandAppRailOnHover: true,
@@ -893,7 +893,7 @@ export function UserProfilePanel({
localStorage.setItem("showHostTags", String(restoredHostTags)); localStorage.setItem("showHostTags", String(restoredHostTags));
window.dispatchEvent(new CustomEvent("showHostTagsChanged")); window.dispatchEvent(new CustomEvent("showHostTagsChanged"));
const restoredTrayOnClick = restore("hostTrayOnClick", "false") === "true"; const restoredTrayOnClick = restore("hostTrayOnClick", "true") !== "false";
setHostTrayOnClick(restoredTrayOnClick); setHostTrayOnClick(restoredTrayOnClick);
localStorage.setItem("hostTrayOnClick", String(restoredTrayOnClick)); localStorage.setItem("hostTrayOnClick", String(restoredTrayOnClick));
@@ -1001,7 +1001,12 @@ export function UserProfilePanel({
} }
function toggle(id: UserProfileSection) { 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() { async function handleStartTotpSetup() {
@@ -1200,7 +1205,7 @@ export function UserProfilePanel({
id="account" id="account"
label={t("newUi.sidebar.userProfile.sectionAccount")} label={t("newUi.sidebar.userProfile.sectionAccount")}
icon={<User className="size-3.5" />} icon={<User className="size-3.5" />}
open={openSection === "account"} open={openSections.has("account")}
onToggle={() => toggle("account")} onToggle={() => toggle("account")}
> >
<div className="flex flex-col gap-0 pt-2"> <div className="flex flex-col gap-0 pt-2">
@@ -1367,7 +1372,7 @@ export function UserProfilePanel({
id="appearance" id="appearance"
label={t("newUi.sidebar.userProfile.sectionAppearance")} label={t("newUi.sidebar.userProfile.sectionAppearance")}
icon={<Palette className="size-3.5" />} icon={<Palette className="size-3.5" />}
open={openSection === "appearance"} open={openSections.has("appearance")}
onToggle={() => toggle("appearance")} onToggle={() => toggle("appearance")}
> >
<div className="flex flex-col gap-4 pt-3"> <div className="flex flex-col gap-4 pt-3">
@@ -1873,7 +1878,7 @@ export function UserProfilePanel({
id="security" id="security"
label={t("newUi.sidebar.userProfile.sectionSecurity")} label={t("newUi.sidebar.userProfile.sectionSecurity")}
icon={<Shield className="size-3.5" />} icon={<Shield className="size-3.5" />}
open={openSection === "security"} open={openSections.has("security")}
onToggle={() => toggle("security")} onToggle={() => toggle("security")}
> >
<div className="flex flex-col gap-4 pt-3"> <div className="flex flex-col gap-4 pt-3">
@@ -2204,7 +2209,7 @@ export function UserProfilePanel({
id="api-keys" id="api-keys"
label={t("newUi.sidebar.userProfile.sectionApiKeys")} label={t("newUi.sidebar.userProfile.sectionApiKeys")}
icon={<Network className="size-3.5" />} icon={<Network className="size-3.5" />}
open={openSection === "api-keys"} open={openSections.has("api-keys")}
onToggle={() => toggle("api-keys")} onToggle={() => toggle("api-keys")}
> >
<div className="flex flex-col gap-2 pt-3"> <div className="flex flex-col gap-2 pt-3">
@@ -2310,7 +2315,7 @@ export function UserProfilePanel({
id="c2s-tunnels" id="c2s-tunnels"
label={t("newUi.sidebar.userProfile.sectionC2sTunnels")} label={t("newUi.sidebar.userProfile.sectionC2sTunnels")}
icon={<Activity className="size-3.5" />} icon={<Activity className="size-3.5" />}
open={openSection === "c2s-tunnels"} open={openSections.has("c2s-tunnels")}
onToggle={() => toggle("c2s-tunnels")} onToggle={() => toggle("c2s-tunnels")}
> >
<C2STunnelPresetManager /> <C2STunnelPresetManager />