mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
fix: general qol additions
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}:
|
||||||
|
|||||||
@@ -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",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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));
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -2120,6 +2120,7 @@ export {
|
|||||||
assignRoleToUser,
|
assignRoleToUser,
|
||||||
removeRoleFromUser,
|
removeRoleFromUser,
|
||||||
shareHost,
|
shareHost,
|
||||||
|
shareFolder,
|
||||||
updateHostAccess,
|
updateHostAccess,
|
||||||
getHostAccess,
|
getHostAccess,
|
||||||
revokeHostAccess,
|
revokeHostAccess,
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
Reference in New Issue
Block a user