chore: improve organization and made a few styling changes in host manager

This commit is contained in:
LukeGus
2025-12-25 02:59:06 -06:00
parent 28ef61bbde
commit 850645843e
56 changed files with 918 additions and 788 deletions

View File

@@ -2224,9 +2224,8 @@
"cannotShareWithSelf": "Cannot share host with yourself", "cannotShareWithSelf": "Cannot share host with yourself",
"noCustomRolesToAssign": "No custom roles available. System roles are auto-assigned.", "noCustomRolesToAssign": "No custom roles available. System roles are auto-assigned.",
"credentialSharingWarning": "Credential Authentication Not Supported for Sharing", "credentialSharingWarning": "Credential Authentication Not Supported for Sharing",
"credentialSharingWarningDescription": "This host uses credential-based authentication. Shared users will not be able to connect because credentials are encrypted per-user and cannot be shared. Please use password or key-based authentication for hosts you intend to share.", "credentialRequired": "Credential is required when sharing a host",
"credentialRequired": "Credential is required when using credential authentication", "credentialRequiredDescription": "This host does not use credential-based authentication. In order to share hosts, due to per-user-encryption, the host must use credential based authentication.",
"credentialRequiredDescription": "This host uses credential-based authentication. Shared users will not be able to connect because credentials are encrypted per-user and cannot be shared. Please use password or key-based authentication for hosts you intend to share.",
"auditLogs": "Audit Logs", "auditLogs": "Audit Logs",
"viewAuditLogs": "View Audit Logs", "viewAuditLogs": "View Audit Logs",
"action": "Action", "action": "Action",

View File

@@ -2,13 +2,13 @@ import React, { useState, useEffect, useCallback, useRef } from "react";
import { LeftSidebar } from "@/ui/desktop/navigation/LeftSidebar.tsx"; import { LeftSidebar } from "@/ui/desktop/navigation/LeftSidebar.tsx";
import { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.tsx"; import { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.tsx";
import { AppView } from "@/ui/desktop/navigation/AppView.tsx"; import { AppView } from "@/ui/desktop/navigation/AppView.tsx";
import { HostManager } from "@/ui/desktop/apps/host-manager/HostManager.tsx"; import { HostManager } from "@/ui/desktop/apps/host-manager/hosts/HostManager.tsx";
import { import {
TabProvider, TabProvider,
useTabs, useTabs,
} from "@/ui/desktop/navigation/tabs/TabContext.tsx"; } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx"; import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx";
import { CommandHistoryProvider } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx"; import { CommandHistoryProvider } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
import { AdminSettings } from "@/ui/desktop/admin/AdminSettings.tsx"; import { AdminSettings } from "@/ui/desktop/admin/AdminSettings.tsx";
import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx"; import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
import { Toaster } from "@/components/ui/sonner.tsx"; import { Toaster } from "@/components/ui/sonner.tsx";
@@ -63,8 +63,10 @@ function AppContent() {
const now = Date.now(); const now = Date.now();
if (now - lastAltPressTime.current < 300) { if (now - lastAltPressTime.current < 300) {
// Use setTheme to properly update React state (not just DOM class) // Use setTheme to properly update React state (not just DOM class)
const currentIsDark = theme === "dark" || const currentIsDark =
(theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches); theme === "dark" ||
(theme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
const newTheme = currentIsDark ? "light" : "dark"; const newTheme = currentIsDark ? "light" : "dark";
setTheme(newTheme); setTheme(newTheme);
console.log("[DEBUG] Theme toggled:", newTheme); console.log("[DEBUG] Theme toggled:", newTheme);

View File

@@ -9,11 +9,7 @@ import {
} from "@/components/ui/tabs.tsx"; } from "@/components/ui/tabs.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import type { import type { SSHHost, DockerContainer, DockerValidation } from "@/types";
SSHHost,
DockerContainer,
DockerValidation,
} from "@/types/index.js";
import { import {
connectDockerSession, connectDockerSession,
disconnectDockerSession, disconnectDockerSession,

View File

@@ -14,7 +14,7 @@ import {
import { Card, CardContent } from "@/components/ui/card.tsx"; import { Card, CardContent } from "@/components/ui/card.tsx";
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react"; import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { SSHHost } from "@/types/index.js"; import type { SSHHost } from "@/types";
import { getCookie, isElectron } from "@/ui/main-axios.ts"; import { getCookie, isElectron } from "@/ui/main-axios.ts";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";

View File

@@ -17,7 +17,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { DockerContainer } from "@/types/index.js"; import type { DockerContainer } from "@/types";
import { import {
startDockerContainer, startDockerContainer,
stopDockerContainer, stopDockerContainer,

View File

@@ -9,7 +9,7 @@ import {
} from "@/components/ui/tabs.tsx"; } from "@/components/ui/tabs.tsx";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { DockerContainer, SSHHost } from "@/types/index.js"; import type { DockerContainer, SSHHost } from "@/types";
import { LogViewer } from "./LogViewer.tsx"; import { LogViewer } from "./LogViewer.tsx";
import { ContainerStats } from "./ContainerStats.tsx"; import { ContainerStats } from "./ContainerStats.tsx";
import { ConsoleTerminal } from "./ConsoleTerminal.tsx"; import { ConsoleTerminal } from "./ConsoleTerminal.tsx";

View File

@@ -9,7 +9,7 @@ import {
} from "@/components/ui/select.tsx"; } from "@/components/ui/select.tsx";
import { Search, Filter } from "lucide-react"; import { Search, Filter } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { DockerContainer } from "@/types/index.js"; import type { DockerContainer } from "@/types";
import { ContainerCard } from "./ContainerCard.tsx"; import { ContainerCard } from "./ContainerCard.tsx";
interface ContainerListProps { interface ContainerListProps {

View File

@@ -7,7 +7,7 @@ import {
} from "@/components/ui/card.tsx"; } from "@/components/ui/card.tsx";
import { Progress } from "@/components/ui/progress.tsx"; import { Progress } from "@/components/ui/progress.tsx";
import { Cpu, MemoryStick, Network, HardDrive, Activity } from "lucide-react"; import { Cpu, MemoryStick, Network, HardDrive, Activity } from "lucide-react";
import type { DockerStats } from "@/types/index.js"; import type { DockerStats } from "@/types";
import { getContainerStats } from "@/ui/main-axios.ts"; import { getContainerStats } from "@/ui/main-axios.ts";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";

View File

@@ -12,7 +12,7 @@ import { Switch } from "@/components/ui/switch.tsx";
import { Label } from "@/components/ui/label.tsx"; import { Label } from "@/components/ui/label.tsx";
import { Download, RefreshCw, Filter } from "lucide-react"; import { Download, RefreshCw, Filter } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { DockerLogOptions } from "@/types/index.js"; import type { DockerLogOptions } from "@/types";
import { getContainerLogs, downloadContainerLogs } from "@/ui/main-axios.ts"; import { getContainerLogs, downloadContainerLogs } from "@/ui/main-axios.ts";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";

View File

@@ -1,23 +1,26 @@
import React, { useState, useEffect, useRef, useCallback } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
import { FileManagerGrid } from "./FileManagerGrid"; import { FileManagerGrid } from "./FileManagerGrid.tsx";
import { FileManagerSidebar } from "./FileManagerSidebar"; import { FileManagerSidebar } from "./FileManagerSidebar.tsx";
import { FileManagerContextMenu } from "./FileManagerContextMenu"; import { FileManagerContextMenu } from "./FileManagerContextMenu.tsx";
import { useFileSelection } from "./hooks/useFileSelection"; import { useFileSelection } from "./hooks/useFileSelection.ts";
import { useDragAndDrop } from "./hooks/useDragAndDrop"; import { useDragAndDrop } from "./hooks/useDragAndDrop.ts";
import { WindowManager, useWindowManager } from "./components/WindowManager"; import {
import { FileWindow } from "./components/FileWindow"; WindowManager,
import { DiffWindow } from "./components/DiffWindow"; useWindowManager,
import { useDragToDesktop } from "../../../hooks/useDragToDesktop"; } from "./components/WindowManager.tsx";
import { useDragToSystemDesktop } from "../../../hooks/useDragToSystemDesktop"; import { FileWindow } from "./components/FileWindow.tsx";
import { DiffWindow } from "./components/DiffWindow.tsx";
import { useDragToDesktop } from "../../../../hooks/useDragToDesktop.ts";
import { useDragToSystemDesktop } from "../../../../hooks/useDragToSystemDesktop.ts";
import { useConfirmation } from "@/hooks/use-confirmation.ts"; import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input.tsx";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx"; import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx"; import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
import { PermissionsDialog } from "./components/PermissionsDialog"; import { PermissionsDialog } from "./components/PermissionsDialog.tsx";
import { CompressDialog } from "./components/CompressDialog"; import { CompressDialog } from "./components/CompressDialog.tsx";
import { import {
Upload, Upload,
FolderPlus, FolderPlus,
@@ -27,7 +30,7 @@ import {
Grid3X3, Grid3X3,
List, List,
} from "lucide-react"; } from "lucide-react";
import { TerminalWindow } from "./components/TerminalWindow"; import { TerminalWindow } from "./components/TerminalWindow.tsx";
import type { SSHHost, FileItem } from "../../../types/index.js"; import type { SSHHost, FileItem } from "../../../types/index.js";
import { import {
listSSHFiles, listSSHFiles,
@@ -55,7 +58,7 @@ import {
extractSSHArchive, extractSSHArchive,
compressSSHFiles, compressSSHFiles,
} from "@/ui/main-axios.ts"; } from "@/ui/main-axios.ts";
import type { SidebarItem } from "./FileManagerSidebar"; import type { SidebarItem } from "./FileManagerSidebar.tsx";
interface FileManagerProps { interface FileManagerProps {
initialHost?: SSHHost | null; initialHost?: SSHHost | null;
@@ -586,7 +589,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
error.message?.includes("established") error.message?.includes("established")
) { ) {
toast.error( toast.error(
t("fileManager.sshConnectionFailed", { name: currentHost?.name, ip: currentHost?.ip, port: currentHost?.port }), t("fileManager.sshConnectionFailed", {
name: currentHost?.name,
ip: currentHost?.ip,
port: currentHost?.port,
}),
); );
} else { } else {
toast.error(t("fileManager.failedToUploadFile")); toast.error(t("fileManager.failedToUploadFile"));
@@ -633,7 +640,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
error.message?.includes("established") error.message?.includes("established")
) { ) {
toast.error( toast.error(
t("fileManager.sshConnectionFailed", { name: currentHost?.name, ip: currentHost?.ip, port: currentHost?.port }), t("fileManager.sshConnectionFailed", {
name: currentHost?.name,
ip: currentHost?.ip,
port: currentHost?.port,
}),
); );
} else { } else {
toast.error(t("fileManager.failedToDownloadFile")); toast.error(t("fileManager.failedToDownloadFile"));
@@ -1943,7 +1954,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
<div className="flex-shrink-0 border-b border-edge"> <div className="flex-shrink-0 border-b border-edge">
<div className="flex items-center justify-between p-3"> <div className="flex items-center justify-between p-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="font-semibold text-foreground">{currentHost.name}</h2> <h2 className="font-semibold text-foreground">
{currentHost.name}
</h2>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{currentHost.ip}:{currentHost.port} {currentHost.ip}:{currentHost.port}
</span> </span>

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils.ts";
import { import {
Download, Download,
Edit3, Edit3,
@@ -20,7 +20,7 @@ import {
FileArchive, FileArchive,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Kbd, KbdGroup } from "@/components/ui/kbd"; import { Kbd, KbdGroup } from "@/components/ui/kbd.tsx";
interface FileItem { interface FileItem {
name: string; name: string;

View File

@@ -1,6 +1,6 @@
import React, { useState, useRef, useCallback, useEffect } from "react"; import React, { useState, useRef, useCallback, useEffect } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils.ts";
import { import {
Folder, Folder,
File, File,

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils.ts";
import { import {
ChevronRight, ChevronRight,
ChevronDown, ChevronDown,
@@ -11,7 +11,7 @@ import {
FolderOpen, FolderOpen,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { SSHHost } from "@/types/index"; import type { SSHHost } from "@/types";
import { import {
getRecentFiles, getRecentFiles,
getPinnedFiles, getPinnedFiles,
@@ -474,9 +474,7 @@ export function FileManagerSidebar({
)} )}
<div <div
className={cn( className={cn(hasQuickAccessItems && "pt-4 border-t border-edge")}
hasQuickAccessItems && "pt-4 border-t border-edge",
)}
> >
<div className="flex items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider"> <div className="flex items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
<Folder className="w-3 h-3" /> <Folder className="w-3 h-3" />

View File

@@ -6,17 +6,17 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog.tsx";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input.tsx";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label.tsx";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface CompressDialogProps { interface CompressDialogProps {

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { DiffEditor } from "@monaco-editor/react"; import { DiffEditor } from "@monaco-editor/react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button.tsx";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -16,8 +16,8 @@ import {
downloadSSHFile, downloadSSHFile,
getSSHStatus, getSSHStatus,
connectSSH, connectSSH,
} from "@/ui/main-axios"; } from "@/ui/main-axios.ts";
import type { FileItem, SSHHost } from "@/types/index"; import type { FileItem, SSHHost } from "@/types";
interface DiffViewerProps { interface DiffViewerProps {
file1: FileItem; file1: FileItem;

View File

@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { DraggableWindow } from "./DraggableWindow"; import { DraggableWindow } from "./DraggableWindow.tsx";
import { DiffViewer } from "./DiffViewer"; import { DiffViewer } from "./DiffViewer.tsx";
import { useWindowManager } from "./WindowManager"; import { useWindowManager } from "./WindowManager.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { FileItem, SSHHost } from "../../../../types/index.js"; import type { FileItem, SSHHost } from "../../../../types/index.js";

View File

@@ -1,5 +1,5 @@
import React, { useState, useRef, useCallback, useEffect } from "react"; import React, { useState, useRef, useCallback, useEffect } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils.ts";
import { Minus, X, Maximize2, Minimize2 } from "lucide-react"; import { Minus, X, Maximize2, Minimize2 } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
FileText, FileText,
@@ -45,7 +45,7 @@ import {
SiMysql, SiMysql,
SiDocker, SiDocker,
} from "react-icons/si"; } from "react-icons/si";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button.tsx";
import CodeMirror from "@uiw/react-codemirror"; import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark"; import { oneDark } from "@codemirror/theme-one-dark";
import { loadLanguage } from "@uiw/codemirror-extensions-langs"; import { loadLanguage } from "@uiw/codemirror-extensions-langs";
@@ -813,7 +813,8 @@ export function FileViewer({
".cm-scroller": { ".cm-scroller": {
overflow: "auto", overflow: "auto",
scrollbarWidth: "thin", scrollbarWidth: "thin",
scrollbarColor: "var(--scrollbar-thumb) var(--scrollbar-track)", scrollbarColor:
"var(--scrollbar-thumb) var(--scrollbar-track)",
}, },
".cm-editor": { ".cm-editor": {
height: "100%", height: "100%",

View File

@@ -1,14 +1,14 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { DraggableWindow } from "./DraggableWindow"; import { DraggableWindow } from "./DraggableWindow.tsx";
import { FileViewer } from "./FileViewer"; import { FileViewer } from "./FileViewer.tsx";
import { useWindowManager } from "./WindowManager"; import { useWindowManager } from "./WindowManager.tsx";
import { import {
downloadSSHFile, downloadSSHFile,
readSSHFile, readSSHFile,
writeSSHFile, writeSSHFile,
getSSHStatus, getSSHStatus,
connectSSH, connectSSH,
} from "@/ui/main-axios"; } from "@/ui/main-axios.ts";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";

View File

@@ -6,11 +6,11 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogFooter, DialogFooter,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog.tsx";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button.tsx";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label.tsx";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox.tsx";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Shield } from "lucide-react"; import { Shield } from "lucide-react";

View File

@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { DraggableWindow } from "./DraggableWindow"; import { DraggableWindow } from "./DraggableWindow.tsx";
import { Terminal } from "@/ui/desktop/apps/terminal/Terminal"; import { Terminal } from "@/ui/desktop/apps/features/terminal/Terminal.tsx";
import { useWindowManager } from "./WindowManager"; import { useWindowManager } from "./WindowManager.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface SSHHost { interface SSHHost {

View File

@@ -16,7 +16,7 @@ import {
type WidgetType, type WidgetType,
type StatsConfig, type StatsConfig,
DEFAULT_STATS_CONFIG, DEFAULT_STATS_CONFIG,
} from "@/types/stats-widgets"; } from "@/types/stats-widgets.ts";
import { import {
CpuWidget, CpuWidget,
MemoryWidget, MemoryWidget,

View File

@@ -0,0 +1,8 @@
export { CpuWidget } from "./CpuWidget.tsx";
export { MemoryWidget } from "./MemoryWidget.tsx";
export { DiskWidget } from "./DiskWidget.tsx";
export { NetworkWidget } from "./NetworkWidget.tsx";
export { UptimeWidget } from "./UptimeWidget.tsx";
export { ProcessesWidget } from "./ProcessesWidget.tsx";
export { SystemWidget } from "./SystemWidget.tsx";
export { LoginStatsWidget } from "./LoginStatsWidget.tsx";

View File

@@ -25,13 +25,13 @@ import {
TERMINAL_THEMES, TERMINAL_THEMES,
DEFAULT_TERMINAL_CONFIG, DEFAULT_TERMINAL_CONFIG,
TERMINAL_FONTS, TERMINAL_FONTS,
} from "@/constants/terminal-themes"; } from "@/constants/terminal-themes.ts";
import type { TerminalConfig } from "@/types"; import type { TerminalConfig } from "@/types";
import { useTheme } from "@/components/theme-provider"; import { useTheme } from "@/components/theme-provider.tsx";
import { useCommandTracker } from "@/ui/hooks/useCommandTracker"; import { useCommandTracker } from "@/ui/hooks/useCommandTracker.ts";
import { highlightTerminalOutput } from "@/lib/terminal-syntax-highlighter.ts"; import { highlightTerminalOutput } from "@/lib/terminal-syntax-highlighter.ts";
import { useCommandHistory as useCommandHistoryHook } from "@/ui/hooks/useCommandHistory"; import { useCommandHistory as useCommandHistoryHook } from "@/ui/hooks/useCommandHistory.ts";
import { useCommandHistory } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx"; import { useCommandHistory } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx"; import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
import { useConfirmation } from "@/hooks/use-confirmation.ts"; import { useConfirmation } from "@/hooks/use-confirmation.ts";
@@ -101,8 +101,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig }; const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig };
// Auto-switch terminal theme based on app theme when using "termix" (default) // Auto-switch terminal theme based on app theme when using "termix" (default)
const isDarkMode = appTheme === "dark" || const isDarkMode =
(appTheme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches); appTheme === "dark" ||
(appTheme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
let themeColors; let themeColors;
if (config.theme === "termix") { if (config.theme === "termix") {
@@ -111,7 +113,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
? TERMINAL_THEMES.termixDark.colors ? TERMINAL_THEMES.termixDark.colors
: TERMINAL_THEMES.termixLight.colors; : TERMINAL_THEMES.termixLight.colors;
} else { } else {
themeColors = TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termixDark.colors; themeColors =
TERMINAL_THEMES[config.theme]?.colors ||
TERMINAL_THEMES.termixDark.colors;
} }
const backgroundColor = themeColors.background; const backgroundColor = themeColors.background;
const fitAddonRef = useRef<FitAddon | null>(null); const fitAddonRef = useRef<FitAddon | null>(null);
@@ -698,7 +702,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
!sudoPromptShownRef.current !sudoPromptShownRef.current
) { ) {
sudoPromptShownRef.current = true; sudoPromptShownRef.current = true;
confirmWithToast(t("terminal.sudoPasswordPopupTitle"), async () => { confirmWithToast(
t("terminal.sudoPasswordPopupTitle"),
async () => {
if ( if (
webSocketRef.current && webSocketRef.current &&
webSocketRef.current.readyState === WebSocket.OPEN webSocketRef.current.readyState === WebSocket.OPEN
@@ -1067,7 +1073,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
? TERMINAL_THEMES.termixDark.colors ? TERMINAL_THEMES.termixDark.colors
: TERMINAL_THEMES.termixLight.colors; : TERMINAL_THEMES.termixLight.colors;
} else { } else {
themeColors = TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termixDark.colors; themeColors =
TERMINAL_THEMES[config.theme]?.colors ||
TERMINAL_THEMES.termixDark.colors;
} }
const fontConfig = TERMINAL_FONTS.find( const fontConfig = TERMINAL_FONTS.find(

View File

@@ -1,6 +1,9 @@
import type { TerminalTheme } from "@/constants/terminal-themes"; import type { TerminalTheme } from "@/constants/terminal-themes.ts";
import { TERMINAL_THEMES, TERMINAL_FONTS } from "@/constants/terminal-themes"; import {
import { useTheme } from "@/components/theme-provider"; TERMINAL_THEMES,
TERMINAL_FONTS,
} from "@/constants/terminal-themes.ts";
import { useTheme } from "@/components/theme-provider.tsx";
interface TerminalPreviewProps { interface TerminalPreviewProps {
theme: string; theme: string;
@@ -24,10 +27,13 @@ export function TerminalPreview({
const { theme: appTheme } = useTheme(); const { theme: appTheme } = useTheme();
// Resolve "termix" to termixDark or termixLight based on app theme // Resolve "termix" to termixDark or termixLight based on app theme
const resolvedTheme = theme === "termix" const resolvedTheme =
? (appTheme === "dark" || (appTheme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches) theme === "termix"
? appTheme === "dark" ||
(appTheme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
? "termixDark" ? "termixDark"
: "termixLight") : "termixLight"
: theme; : theme;
return ( return (
@@ -41,8 +47,12 @@ export function TerminalPreview({
TERMINAL_FONTS[0].fallback, TERMINAL_FONTS[0].fallback,
letterSpacing: `${letterSpacing}px`, letterSpacing: `${letterSpacing}px`,
lineHeight, lineHeight,
background: TERMINAL_THEMES[resolvedTheme]?.colors.background || "var(--bg-base)", background:
color: TERMINAL_THEMES[resolvedTheme]?.colors.foreground || "var(--foreground)", TERMINAL_THEMES[resolvedTheme]?.colors.background ||
"var(--bg-base)",
color:
TERMINAL_THEMES[resolvedTheme]?.colors.foreground ||
"var(--foreground)",
}} }}
> >
<div> <div>
@@ -50,7 +60,9 @@ export function TerminalPreview({
user@termix user@termix
</span> </span>
<span>:</span> <span>:</span>
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>~</span> <span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>
~
</span>
<span>$ ls -la</span> <span>$ ls -la</span>
</div> </div>
<div> <div>
@@ -81,7 +93,9 @@ export function TerminalPreview({
user@termix user@termix
</span> </span>
<span>:</span> <span>:</span>
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>~</span> <span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>
~
</span>
<span>$ </span> <span>$ </span>
<span <span
className="inline-block" className="inline-block"
@@ -93,7 +107,8 @@ export function TerminalPreview({
: cursorStyle === "bar" : cursorStyle === "bar"
? `${fontSize}px` ? `${fontSize}px`
: `${fontSize}px`, : `${fontSize}px`,
background: TERMINAL_THEMES[resolvedTheme]?.colors.cursor || "#f7f7f7", background:
TERMINAL_THEMES[resolvedTheme]?.colors.cursor || "#f7f7f7",
animation: cursorBlink ? "blink 1s step-end infinite" : "none", animation: cursorBlink ? "blink 1s step-end infinite" : "none",
verticalAlign: verticalAlign:
cursorStyle === "underline" ? "bottom" : "text-bottom", cursorStyle === "underline" ? "bottom" : "text-bottom",

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TunnelViewer } from "@/ui/desktop/apps/tunnel/TunnelViewer.tsx"; import { TunnelViewer } from "@/ui/desktop/apps/features/tunnel/TunnelViewer.tsx";
import { import {
getSSHHosts, getSSHHosts,
getTunnelStatuses, getTunnelStatuses,

View File

@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { useSidebar } from "@/components/ui/sidebar.tsx"; import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Separator } from "@/components/ui/separator.tsx"; import { Separator } from "@/components/ui/separator.tsx";
import { Tunnel } from "@/ui/desktop/apps/tunnel/Tunnel.tsx"; import { Tunnel } from "@/ui/desktop/apps/features/tunnel/Tunnel.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface HostConfig { interface HostConfig {

View File

@@ -1,590 +0,0 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { Label } from "@/components/ui/label.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Badge } from "@/components/ui/badge.tsx";
import {
AlertCircle,
Plus,
Trash2,
Users,
Shield,
Clock,
UserCircle,
Check,
ChevronsUpDown,
} from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getRoles,
getUserList,
getUserInfo,
shareHost,
getHostAccess,
revokeHostAccess,
getSSHHostById,
type Role,
type AccessRecord,
type SSHHost,
} from "@/ui/main-axios.ts";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command.tsx";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover.tsx";
import { cn } from "@/lib/utils";
interface HostSharingTabProps {
hostId: number | undefined;
isNewHost: boolean;
}
interface User {
id: string;
username: string;
is_admin: boolean;
}
const PERMISSION_LEVELS = [
{ value: "view", labelKey: "rbac.view" },
{ value: "manage", labelKey: "rbac.manage" },
];
export function HostSharingTab({
hostId,
isNewHost,
}: HostSharingTabProps): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [shareType, setShareType] = React.useState<"user" | "role">("user");
const [selectedUserId, setSelectedUserId] = React.useState<string>("");
const [selectedRoleId, setSelectedRoleId] = React.useState<number | null>(
null,
);
const [permissionLevel, setPermissionLevel] = React.useState("view");
const [expiresInHours, setExpiresInHours] = React.useState<string>("");
const [roles, setRoles] = React.useState<Role[]>([]);
const [users, setUsers] = React.useState<User[]>([]);
const [accessList, setAccessList] = React.useState<AccessRecord[]>([]);
const [loading, setLoading] = React.useState(false);
const [currentUserId, setCurrentUserId] = React.useState<string>("");
const [hostData, setHostData] = React.useState<SSHHost | null>(null);
const [userComboOpen, setUserComboOpen] = React.useState(false);
const [roleComboOpen, setRoleComboOpen] = React.useState(false);
// Load roles
const loadRoles = React.useCallback(async () => {
try {
const response = await getRoles();
setRoles(response.roles || []);
} catch (error) {
console.error("Failed to load roles:", error);
setRoles([]);
}
}, []);
// Load users
const loadUsers = React.useCallback(async () => {
try {
const response = await getUserList();
// Map UserInfo to User format
const mappedUsers = (response.users || []).map((user) => ({
id: user.id,
username: user.username,
is_admin: user.is_admin,
}));
setUsers(mappedUsers);
} catch (error) {
console.error("Failed to load users:", error);
setUsers([]);
}
}, []);
// Load access list
const loadAccessList = React.useCallback(async () => {
if (!hostId) return;
setLoading(true);
try {
const response = await getHostAccess(hostId);
setAccessList(response.accessList || []);
} catch (error) {
console.error("Failed to load access list:", error);
setAccessList([]);
} finally {
setLoading(false);
}
}, [hostId]);
// Load host data
const loadHostData = React.useCallback(async () => {
if (!hostId) return;
try {
const host = await getSSHHostById(hostId);
setHostData(host);
} catch (error) {
console.error("Failed to load host data:", error);
setHostData(null);
}
}, [hostId]);
React.useEffect(() => {
loadRoles();
loadUsers();
if (!isNewHost) {
loadAccessList();
loadHostData();
}
}, [loadRoles, loadUsers, loadAccessList, loadHostData, isNewHost]);
// Load current user ID
React.useEffect(() => {
const fetchCurrentUser = async () => {
try {
const userInfo = await getUserInfo();
setCurrentUserId(userInfo.userId);
} catch (error) {
console.error("Failed to load current user:", error);
}
};
fetchCurrentUser();
}, []);
// Share host
const handleShare = async () => {
if (!hostId) {
toast.error(t("rbac.saveHostFirst"));
return;
}
if (shareType === "user" && !selectedUserId) {
toast.error(t("rbac.selectUser"));
return;
}
if (shareType === "role" && !selectedRoleId) {
toast.error(t("rbac.selectRole"));
return;
}
// Prevent sharing with self
if (shareType === "user" && selectedUserId === currentUserId) {
toast.error(t("rbac.cannotShareWithSelf"));
return;
}
try {
await shareHost(hostId, {
targetType: shareType,
targetUserId: shareType === "user" ? selectedUserId : undefined,
targetRoleId: shareType === "role" ? selectedRoleId : undefined,
permissionLevel,
durationHours: expiresInHours
? parseInt(expiresInHours, 10)
: undefined,
});
toast.success(t("rbac.sharedSuccessfully"));
setSelectedUserId("");
setSelectedRoleId(null);
setExpiresInHours("");
loadAccessList();
} catch (error) {
toast.error(t("rbac.failedToShare"));
}
};
// Revoke access
const handleRevoke = async (accessId: number) => {
if (!hostId) return;
const confirmed = await confirmWithToast({
title: t("rbac.confirmRevokeAccess"),
description: t("rbac.confirmRevokeAccessDescription"),
confirmText: t("common.revoke"),
cancelText: t("common.cancel"),
});
if (!confirmed) return;
try {
await revokeHostAccess(hostId, accessId);
toast.success(t("rbac.accessRevokedSuccessfully"));
loadAccessList();
} catch (error) {
toast.error(t("rbac.failedToRevokeAccess"));
}
};
// Format date
const formatDate = (dateString: string | null) => {
if (!dateString) return "-";
return new Date(dateString).toLocaleString();
};
// Check if expired
const isExpired = (expiresAt: string | null) => {
if (!expiresAt) return false;
return new Date(expiresAt) < new Date();
};
// Filter out current user from the users list
const availableUsers = React.useMemo(() => {
return users.filter((user) => user.id !== currentUserId);
}, [users, currentUserId]);
const selectedUser = availableUsers.find((u) => u.id === selectedUserId);
const selectedRole = roles.find((r) => r.id === selectedRoleId);
if (isNewHost) {
return (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("rbac.saveHostFirst")}</AlertTitle>
<AlertDescription>
{t("rbac.saveHostFirstDescription")}
</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-6">
{/* Credential Requirement Warning */}
{!hostData?.credentialId && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("rbac.credentialRequired")}</AlertTitle>
<AlertDescription>
{t("rbac.credentialRequiredDescription")}
</AlertDescription>
</Alert>
)}
{/* Share Form */}
<div className="space-y-4 border rounded-lg p-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Plus className="h-5 w-5" />
{t("rbac.shareHost")}
</h3>
{/* Share Type Selection */}
<Tabs
value={shareType}
onValueChange={(v) => setShareType(v as "user" | "role")}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="user" className="flex items-center gap-2">
<UserCircle className="h-4 w-4" />
{t("rbac.shareWithUser")}
</TabsTrigger>
<TabsTrigger value="role" className="flex items-center gap-2">
<Shield className="h-4 w-4" />
{t("rbac.shareWithRole")}
</TabsTrigger>
</TabsList>
<TabsContent value="user" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="user-select">{t("rbac.selectUser")}</Label>
<Popover open={userComboOpen} onOpenChange={setUserComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={userComboOpen}
className="w-full justify-between"
>
{selectedUser
? `${selectedUser.username}${selectedUser.is_admin ? " (Admin)" : ""}`
: t("rbac.selectUserPlaceholder")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder={t("rbac.searchUsers")} />
<CommandEmpty>{t("rbac.noUserFound")}</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto thin-scrollbar">
{availableUsers.map((user) => (
<CommandItem
key={user.id}
value={`${user.username} ${user.id}`}
onSelect={() => {
setSelectedUserId(user.id);
setUserComboOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedUserId === user.id
? "opacity-100"
: "opacity-0",
)}
/>
{user.username}
{user.is_admin ? " (Admin)" : ""}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
</TabsContent>
<TabsContent value="role" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="role-select">{t("rbac.selectRole")}</Label>
<Popover open={roleComboOpen} onOpenChange={setRoleComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={roleComboOpen}
className="w-full justify-between"
>
{selectedRole
? `${t(selectedRole.displayName)}${selectedRole.isSystem ? ` (${t("rbac.systemRole")})` : ""}`
: t("rbac.selectRolePlaceholder")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder={t("rbac.searchRoles")} />
<CommandEmpty>{t("rbac.noRoleFound")}</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto thin-scrollbar">
{roles.map((role) => (
<CommandItem
key={role.id}
value={`${role.displayName} ${role.name} ${role.id}`}
onSelect={() => {
setSelectedRoleId(role.id);
setRoleComboOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedRoleId === role.id
? "opacity-100"
: "opacity-0",
)}
/>
{t(role.displayName)}
{role.isSystem ? ` (${t("rbac.systemRole")})` : ""}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
</TabsContent>
</Tabs>
{/* Permission Level */}
<div className="space-y-2">
<Label htmlFor="permission-level">{t("rbac.permissionLevel")}</Label>
<Select
value={permissionLevel || "use"}
onValueChange={(v) => setPermissionLevel(v || "use")}
>
<SelectTrigger id="permission-level">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PERMISSION_LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value}>
{t(level.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Expiration */}
<div className="space-y-2">
<Label htmlFor="expires-in">{t("rbac.durationHours")}</Label>
<Input
id="expires-in"
type="number"
value={expiresInHours}
onChange={(e) => {
const value = e.target.value;
if (value === "" || /^\d+$/.test(value)) {
setExpiresInHours(value);
}
}}
placeholder={t("rbac.neverExpires")}
min="1"
/>
</div>
<Button
type="button"
onClick={handleShare}
className="w-full"
disabled={!hostData?.credentialId}
>
<Plus className="h-4 w-4 mr-2" />
{t("rbac.share")}
</Button>
</div>
{/* Access List */}
<div className="space-y-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Users className="h-5 w-5" />
{t("rbac.accessList")}
</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("rbac.type")}</TableHead>
<TableHead>{t("rbac.target")}</TableHead>
<TableHead>{t("rbac.permissionLevel")}</TableHead>
<TableHead>{t("rbac.grantedBy")}</TableHead>
<TableHead>{t("rbac.expires")}</TableHead>
<TableHead>{t("rbac.accessCount")}</TableHead>
<TableHead className="text-right">
{t("common.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground"
>
{t("common.loading")}
</TableCell>
</TableRow>
) : accessList.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground"
>
{t("rbac.noAccessRecords")}
</TableCell>
</TableRow>
) : (
accessList.map((access) => (
<TableRow
key={access.id}
className={isExpired(access.expiresAt) ? "opacity-50" : ""}
>
<TableCell>
{access.targetType === "user" ? (
<Badge
variant="outline"
className="flex items-center gap-1 w-fit"
>
<UserCircle className="h-3 w-3" />
{t("rbac.user")}
</Badge>
) : (
<Badge
variant="outline"
className="flex items-center gap-1 w-fit"
>
<Shield className="h-3 w-3" />
{t("rbac.role")}
</Badge>
)}
</TableCell>
<TableCell>
{access.targetType === "user"
? access.username
: t(access.roleDisplayName || access.roleName || "")}
</TableCell>
<TableCell>
<Badge variant="secondary">{access.permissionLevel}</Badge>
</TableCell>
<TableCell>{access.grantedByUsername}</TableCell>
<TableCell>
{access.expiresAt ? (
<div className="flex items-center gap-2">
<Clock className="h-3 w-3" />
<span
className={
isExpired(access.expiresAt) ? "text-red-500" : ""
}
>
{formatDate(access.expiresAt)}
{isExpired(access.expiresAt) && (
<span className="ml-2">({t("rbac.expired")})</span>
)}
</span>
</div>
) : (
t("rbac.never")
)}
</TableCell>
<TableCell>{access.accessCount}</TableCell>
<TableCell className="text-right">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => handleRevoke(access.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -6,9 +6,9 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogFooter, DialogFooter,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog.tsx";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button.tsx";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
Folder, Folder,

View File

@@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button.tsx";
import { import {
Form, Form,
FormControl, FormControl,
@@ -10,13 +10,18 @@ import {
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
} from "@/components/ui/form"; } from "@/components/ui/form.tsx";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input.tsx";
import { PasswordInput } from "@/components/ui/password-input"; import { PasswordInput } from "@/components/ui/password-input.tsx";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area.tsx";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import {
import { Alert, AlertDescription } from "@/components/ui/alert"; Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -28,18 +33,18 @@ import {
detectPublicKeyType, detectPublicKeyType,
generatePublicKeyFromPrivate, generatePublicKeyFromPrivate,
generateKeyPair, generateKeyPair,
} from "@/ui/main-axios"; } from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import CodeMirror from "@uiw/react-codemirror"; import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark"; import { oneDark } from "@codemirror/theme-one-dark";
import { githubLight } from "@uiw/codemirror-theme-github"; import { githubLight } from "@uiw/codemirror-theme-github";
import { EditorView } from "@codemirror/view"; import { EditorView } from "@codemirror/view";
import { useTheme } from "@/components/theme-provider"; import { useTheme } from "@/components/theme-provider.tsx";
import type { import type {
Credential, Credential,
CredentialEditorProps, CredentialEditorProps,
CredentialData, CredentialData,
} from "../../../../types/index.js"; } from "../../../../../types";
export function CredentialEditor({ export function CredentialEditor({
editingCredential, editingCredential,
@@ -635,7 +640,7 @@ export function CredentialEditor({
)} )}
<input <input
type="text" type="text"
className="flex-1 min-w-[60px] border-none outline-none bg-transparent p-0 h-6 text-sm" className="flex-1 min-w-[60px] border-none outline-none bg-transparent text-foreground placeholder:text-muted-foreground p-0 h-6 text-sm"
value={tagInput} value={tagInput}
onChange={(e) => setTagInput(e.target.value)} onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -950,7 +955,7 @@ export function CredentialEditor({
"placeholders.pastePrivateKey", "placeholders.pastePrivateKey",
)} )}
theme={editorTheme} theme={editorTheme}
className="border border-input rounded-md" className="border border-input rounded-md overflow-hidden"
minHeight="120px" minHeight="120px"
basicSetup={{ basicSetup={{
lineNumbers: true, lineNumbers: true,
@@ -1113,7 +1118,7 @@ export function CredentialEditor({
}} }}
placeholder={t("placeholders.pastePublicKey")} placeholder={t("placeholders.pastePublicKey")}
theme={editorTheme} theme={editorTheme}
className="border border-input rounded-md" className="border border-input rounded-md overflow-hidden"
minHeight="120px" minHeight="120px"
basicSetup={{ basicSetup={{
lineNumbers: true, lineNumbers: true,

View File

@@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input.tsx";
import { FormControl, FormItem, FormLabel } from "@/components/ui/form.tsx"; import { FormControl, FormItem, FormLabel } from "@/components/ui/form.tsx";
import { getCredentials } from "@/ui/main-axios.ts"; import { getCredentials } from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { Credential } from "../../../../types"; import type { Credential } from "../../../../../types";
interface CredentialSelectorProps { interface CredentialSelectorProps {
value?: number | null; value?: number | null;

View File

@@ -1,22 +1,22 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button.tsx";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card.tsx";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge.tsx";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator.tsx";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area.tsx";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetFooter, SheetFooter,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet.tsx";
import { import {
Key, Key,
User, User,
@@ -34,7 +34,7 @@ import {
CheckCircle, CheckCircle,
FileText, FileText,
} from "lucide-react"; } from "lucide-react";
import { getCredentialDetails, getCredentialHosts } from "@/ui/main-axios"; import { getCredentialDetails, getCredentialHosts } from "@/ui/main-axios.ts";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { import type {

View File

@@ -1,33 +1,33 @@
import React, { useState, useEffect, useMemo, useRef } from "react"; import React, { useState, useEffect, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button.tsx";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge.tsx";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input.tsx";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area.tsx";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion"; } from "@/components/ui/accordion.tsx";
import { Sheet, SheetContent } from "@/components/ui/sheet"; import { Sheet, SheetContent } from "@/components/ui/sheet.tsx";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip.tsx";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover.tsx";
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
CommandGroup, CommandGroup,
CommandInput, CommandInput,
CommandItem, CommandItem,
} from "@/components/ui/command"; } from "@/components/ui/command.tsx";
import { import {
Search, Search,
Key, Key,
@@ -46,7 +46,7 @@ import {
User, User,
ChevronsUpDown, ChevronsUpDown,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils.ts";
import { import {
getCredentials, getCredentials,
deleteCredential, deleteCredential,
@@ -54,15 +54,12 @@ import {
renameCredentialFolder, renameCredentialFolder,
deployCredentialToHost, deployCredentialToHost,
getSSHHosts, getSSHHosts,
} from "@/ui/main-axios"; } from "@/ui/main-axios.ts";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts"; import { useConfirmation } from "@/hooks/use-confirmation.ts";
import CredentialViewer from "./CredentialViewer"; import CredentialViewer from "./CredentialViewer.tsx";
import type { import type { Credential, CredentialsManagerProps } from "../../../../../types";
Credential,
CredentialsManagerProps,
} from "../../../../types/index.js";
export function CredentialsManager({ export function CredentialsManager({
onEditCredential, onEditCredential,
@@ -622,7 +619,8 @@ export function CredentialsManager({
{credential.username} {credential.username}
</p> </p>
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{t("credentials.idLabel")} {credential.id} {t("credentials.idLabel")}{" "}
{credential.id}
</p> </p>
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{credential.authType === "password" {credential.authType === "password"
@@ -867,7 +865,8 @@ export function CredentialsManager({
{t("credentials.keyType")} {t("credentials.keyType")}
</div> </div>
<div className="text-sm font-medium"> <div className="text-sm font-medium">
{deployingCredential.keyType || t("credentials.sshKey")} {deployingCredential.keyType ||
t("credentials.sshKey")}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { HostManagerViewer } from "@/ui/desktop/apps/host-manager/HostManagerViewer.tsx"; import { HostManagerViewer } from "@/ui/desktop/apps/host-manager/hosts/HostManagerViewer.tsx";
import { import {
Tabs, Tabs,
TabsContent, TabsContent,
@@ -7,9 +7,9 @@ import {
TabsTrigger, TabsTrigger,
} from "@/components/ui/tabs.tsx"; } from "@/components/ui/tabs.tsx";
import { Separator } from "@/components/ui/separator.tsx"; import { Separator } from "@/components/ui/separator.tsx";
import { HostManagerEditor } from "@/ui/desktop/apps/host-manager/HostManagerEditor.tsx"; import { HostManagerEditor } from "@/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx";
import { CredentialsManager } from "@/ui/desktop/apps/credentials/CredentialsManager.tsx"; import { CredentialsManager } from "@/ui/desktop/apps/host-manager/credentials/CredentialsManager.tsx";
import { CredentialEditor } from "@/ui/desktop/apps/credentials/CredentialEditor.tsx"; import { CredentialEditor } from "@/ui/desktop/apps/host-manager/credentials/CredentialEditor.tsx";
import { useSidebar } from "@/components/ui/sidebar.tsx"; import { useSidebar } from "@/components/ui/sidebar.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { SSHHost, HostManagerProps } from "../../../types/index"; import type { SSHHost, HostManagerProps } from "../../../types/index";
@@ -117,10 +117,16 @@ export function HostManager({
className="flex-1 flex flex-col h-full min-h-0" className="flex-1 flex flex-col h-full min-h-0"
> >
<TabsList className="bg-elevated border-2 border-edge mt-1.5"> <TabsList className="bg-elevated border-2 border-edge mt-1.5">
<TabsTrigger value="host_viewer" className="bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"> <TabsTrigger
value="host_viewer"
className="bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
>
{t("hosts.hostViewer")} {t("hosts.hostViewer")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="add_host" className="bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"> <TabsTrigger
value="add_host"
className="bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
>
{editingHost {editingHost
? editingHost.id ? editingHost.id
? t("hosts.editHost") ? t("hosts.editHost")
@@ -128,10 +134,16 @@ export function HostManager({
: t("hosts.addHost")} : t("hosts.addHost")}
</TabsTrigger> </TabsTrigger>
<div className="h-6 w-px bg-border-base mx-1"></div> <div className="h-6 w-px bg-border-base mx-1"></div>
<TabsTrigger value="credentials" className="bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"> <TabsTrigger
value="credentials"
className="bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
>
{t("credentials.credentialsViewer")} {t("credentials.credentialsViewer")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="add_credential" className="bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"> <TabsTrigger
value="add_credential"
className="bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
>
{editingCredential {editingCredential
? t("credentials.editCredential") ? t("credentials.editCredential")
: t("credentials.addCredential")} : t("credentials.addCredential")}

View File

@@ -1,7 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils.ts";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { import {
@@ -37,13 +37,15 @@ import {
getSnippets, getSnippets,
} from "@/ui/main-axios.ts"; } from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CredentialSelector } from "@/ui/desktop/apps/credentials/CredentialSelector.tsx"; import { CredentialSelector } from "@/ui/desktop/apps/host-manager/credentials/CredentialSelector.tsx";
import { HostSharingTab } from "./HostSharingTab.tsx"; import { HostSharingTab } from "./HostSharingTab.tsx";
import CodeMirror from "@uiw/react-codemirror"; import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark"; import { oneDark } from "@codemirror/theme-one-dark";
import { githubLight } from "@uiw/codemirror-theme-github";
import { EditorView } from "@codemirror/view"; import { EditorView } from "@codemirror/view";
import type { StatsConfig } from "@/types/stats-widgets"; import { useTheme } from "@/components/theme-provider.tsx";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets"; import type { StatsConfig } from "@/types/stats-widgets.ts";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets.ts";
import { Checkbox } from "@/components/ui/checkbox.tsx"; import { Checkbox } from "@/components/ui/checkbox.tsx";
import { import {
Select, Select,
@@ -86,8 +88,8 @@ import {
BELL_STYLES, BELL_STYLES,
FAST_SCROLL_MODIFIERS, FAST_SCROLL_MODIFIERS,
DEFAULT_TERMINAL_CONFIG, DEFAULT_TERMINAL_CONFIG,
} from "@/constants/terminal-themes"; } from "@/constants/terminal-themes.ts";
import { TerminalPreview } from "@/ui/desktop/apps/terminal/TerminalPreview.tsx"; import { TerminalPreview } from "@/ui/desktop/apps/features/terminal/TerminalPreview.tsx";
import type { TerminalConfig, SSHHost, Credential } from "@/types"; import type { TerminalConfig, SSHHost, Credential } from "@/types";
import { Plus, X, Check, ChevronsUpDown, Save } from "lucide-react"; import { Plus, X, Check, ChevronsUpDown, Save } from "lucide-react";
@@ -300,6 +302,14 @@ export function HostManagerEditor({
onFormSubmit, onFormSubmit,
}: SSHManagerHostEditorProps) { }: SSHManagerHostEditorProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { theme: appTheme } = useTheme();
// Determine CodeMirror theme based on app theme
const isDarkMode =
appTheme === "dark" ||
(appTheme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
const editorTheme = isDarkMode ? oneDark : githubLight;
const [folders, setFolders] = useState<string[]>([]); const [folders, setFolders] = useState<string[]>([]);
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]); const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
const [hosts, setHosts] = useState<SSHHost[]>([]); const [hosts, setHosts] = useState<SSHHost[]>([]);
@@ -1160,18 +1170,40 @@ export function HostManagerEditor({
className="w-full" className="w-full"
> >
<TabsList className="bg-button border border-edge-medium"> <TabsList className="bg-button border border-edge-medium">
<TabsTrigger value="general" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"> <TabsTrigger
value="general"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.general")} {t("hosts.general")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="terminal" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"> <TabsTrigger
value="terminal"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.terminal")} {t("hosts.terminal")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="docker" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium">Docker</TabsTrigger> <TabsTrigger
<TabsTrigger value="tunnel" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium">{t("hosts.tunnel")}</TabsTrigger> value="docker"
<TabsTrigger value="file_manager" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"> className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
Docker
</TabsTrigger>
<TabsTrigger
value="tunnel"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.tunnel")}
</TabsTrigger>
<TabsTrigger
value="file_manager"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.fileManager")} {t("hosts.fileManager")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="statistics" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"> <TabsTrigger
value="statistics"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.statistics")} {t("hosts.statistics")}
</TabsTrigger> </TabsTrigger>
{!editingHost?.isShared && ( {!editingHost?.isShared && (
@@ -1359,7 +1391,7 @@ export function HostManagerEditor({
))} ))}
<input <input
type="text" type="text"
className="flex-1 min-w-[60px] border-none outline-none bg-transparent p-0 h-6" className="flex-1 min-w-[60px] border-none outline-none bg-transparent text-foreground placeholder:text-muted-foreground p-0 h-6"
value={tagInput} value={tagInput}
onChange={(e) => setTagInput(e.target.value)} onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -1444,14 +1476,30 @@ export function HostManagerEditor({
className="flex-1 flex flex-col h-full min-h-0" className="flex-1 flex flex-col h-full min-h-0"
> >
<TabsList className="bg-button border border-edge-medium"> <TabsList className="bg-button border border-edge-medium">
<TabsTrigger value="password" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"> <TabsTrigger
value="password"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.password")} {t("hosts.password")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="key" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium">{t("hosts.key")}</TabsTrigger> <TabsTrigger
<TabsTrigger value="credential" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"> value="key"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.key")}
</TabsTrigger>
<TabsTrigger
value="credential"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.credential")} {t("hosts.credential")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="none" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium">{t("hosts.none")}</TabsTrigger> <TabsTrigger
value="none"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.none")}
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="password"> <TabsContent value="password">
<FormField <FormField
@@ -1559,8 +1607,8 @@ export function HostManagerEditor({
placeholder={t( placeholder={t(
"placeholders.pastePrivateKey", "placeholders.pastePrivateKey",
)} )}
theme={oneDark} theme={editorTheme}
className="border border-input rounded-md" className="border border-input rounded-md overflow-hidden"
minHeight="120px" minHeight="120px"
basicSetup={{ basicSetup={{
lineNumbers: true, lineNumbers: true,
@@ -1574,7 +1622,8 @@ export function HostManagerEditor({
".cm-scroller": { ".cm-scroller": {
overflow: "auto", overflow: "auto",
scrollbarWidth: "thin", scrollbarWidth: "thin",
scrollbarColor: "var(--scrollbar-thumb) var(--scrollbar-track)", scrollbarColor:
"var(--scrollbar-thumb) var(--scrollbar-track)",
}, },
}), }),
]} ]}
@@ -1897,7 +1946,9 @@ export function HostManagerEditor({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder={t("placeholders.socks5Host")} placeholder={t(
"placeholders.socks5Host",
)}
{...field} {...field}
onBlur={(e) => { onBlur={(e) => {
field.onChange( field.onChange(
@@ -1925,7 +1976,9 @@ export function HostManagerEditor({
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
placeholder={t("placeholders.socks5Port")} placeholder={t(
"placeholders.socks5Port",
)}
{...field} {...field}
onChange={(e) => onChange={(e) =>
field.onChange( field.onChange(
@@ -1947,7 +2000,8 @@ export function HostManagerEditor({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t("hosts.socks5Username")} {t("hosts.optional")} {t("hosts.socks5Username")}{" "}
{t("hosts.optional")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
@@ -1971,7 +2025,8 @@ export function HostManagerEditor({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t("hosts.socks5Password")} {t("hosts.optional")} {t("hosts.socks5Password")}{" "}
{t("hosts.optional")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<PasswordInput <PasswordInput
@@ -2059,7 +2114,9 @@ export function HostManagerEditor({
{t("hosts.socks5Host")} {t("hosts.socks5Host")}
</FormLabel> </FormLabel>
<Input <Input
placeholder={t("placeholders.socks5Host")} placeholder={t(
"placeholders.socks5Host",
)}
value={node.host} value={node.host}
onChange={(e) => { onChange={(e) => {
const currentChain = const currentChain =
@@ -2104,7 +2161,9 @@ export function HostManagerEditor({
</FormLabel> </FormLabel>
<Input <Input
type="number" type="number"
placeholder={t("placeholders.socks5Port")} placeholder={t(
"placeholders.socks5Port",
)}
value={node.port} value={node.port}
onChange={(e) => { onChange={(e) => {
const currentChain = const currentChain =
@@ -2167,7 +2226,8 @@ export function HostManagerEditor({
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-2"> <div className="space-y-2">
<FormLabel> <FormLabel>
{t("hosts.socks5Username")} {t("hosts.optional")} {t("hosts.socks5Username")}{" "}
{t("hosts.optional")}
</FormLabel> </FormLabel>
<Input <Input
placeholder={t("hosts.username")} placeholder={t("hosts.username")}
@@ -2211,7 +2271,8 @@ export function HostManagerEditor({
<div className="space-y-2"> <div className="space-y-2">
<FormLabel> <FormLabel>
{t("hosts.socks5Password")} {t("hosts.optional")} {t("hosts.socks5Password")}{" "}
{t("hosts.optional")}
</FormLabel> </FormLabel>
<PasswordInput <PasswordInput
placeholder={t("hosts.password")} placeholder={t("hosts.password")}
@@ -2755,7 +2816,8 @@ export function HostManagerEditor({
<FormLabel>{t("hosts.moshCommand")}</FormLabel> <FormLabel>{t("hosts.moshCommand")}</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder={t("placeholders.moshCommand")} {...field} placeholder={t("placeholders.moshCommand")}
{...field}
onBlur={(e) => { onBlur={(e) => {
field.onChange(e.target.value.trim()); field.onChange(e.target.value.trim());
field.onBlur(); field.onBlur();
@@ -3071,7 +3133,9 @@ export function HostManagerEditor({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder={t("placeholders.defaultPort")} placeholder={t(
"placeholders.defaultPort",
)}
{...sourcePortField} {...sourcePortField}
/> />
</FormControl> </FormControl>
@@ -3090,7 +3154,9 @@ export function HostManagerEditor({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder={t("placeholders.defaultEndpointPort")} placeholder={t(
"placeholders.defaultEndpointPort",
)}
{...endpointPortField} {...endpointPortField}
/> />
</FormControl> </FormControl>

View File

@@ -68,10 +68,10 @@ import type {
SSHHost, SSHHost,
SSHFolder, SSHFolder,
SSHManagerHostViewerProps, SSHManagerHostViewerProps,
} from "../../../../types/index.js"; } from "../../../../../types";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets"; import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets.ts";
import { FolderEditDialog } from "./components/FolderEditDialog"; import { FolderEditDialog } from "../components/FolderEditDialog.tsx";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -0,0 +1,606 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { Label } from "@/components/ui/label.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Badge } from "@/components/ui/badge.tsx";
import {
AlertCircle,
Plus,
Trash2,
Users,
Shield,
Clock,
UserCircle,
Check,
ChevronsUpDown,
} from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getRoles,
getUserList,
getUserInfo,
shareHost,
getHostAccess,
revokeHostAccess,
getSSHHostById,
type Role,
type AccessRecord,
type SSHHost,
} from "@/ui/main-axios.ts";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command.tsx";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover.tsx";
import { cn } from "@/lib/utils.ts";
interface HostSharingTabProps {
hostId: number | undefined;
isNewHost: boolean;
}
interface User {
id: string;
username: string;
is_admin: boolean;
}
const PERMISSION_LEVELS = [
{ value: "view", labelKey: "rbac.view" },
{ value: "manage", labelKey: "rbac.manage" },
];
export function HostSharingTab({
hostId,
isNewHost,
}: HostSharingTabProps): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [shareType, setShareType] = React.useState<"user" | "role">("user");
const [selectedUserId, setSelectedUserId] = React.useState<string>("");
const [selectedRoleId, setSelectedRoleId] = React.useState<number | null>(
null,
);
const [permissionLevel, setPermissionLevel] = React.useState("view");
const [expiresInHours, setExpiresInHours] = React.useState<string>("");
const [roles, setRoles] = React.useState<Role[]>([]);
const [users, setUsers] = React.useState<User[]>([]);
const [accessList, setAccessList] = React.useState<AccessRecord[]>([]);
const [loading, setLoading] = React.useState(false);
const [currentUserId, setCurrentUserId] = React.useState<string>("");
const [hostData, setHostData] = React.useState<SSHHost | null>(null);
const [userComboOpen, setUserComboOpen] = React.useState(false);
const [roleComboOpen, setRoleComboOpen] = React.useState(false);
// Load roles
const loadRoles = React.useCallback(async () => {
try {
const response = await getRoles();
setRoles(response.roles || []);
} catch (error) {
console.error("Failed to load roles:", error);
setRoles([]);
}
}, []);
// Load users
const loadUsers = React.useCallback(async () => {
try {
const response = await getUserList();
// Map UserInfo to User format
const mappedUsers = (response.users || []).map((user) => ({
id: user.id,
username: user.username,
is_admin: user.is_admin,
}));
setUsers(mappedUsers);
} catch (error) {
console.error("Failed to load users:", error);
setUsers([]);
}
}, []);
// Load access list
const loadAccessList = React.useCallback(async () => {
if (!hostId) return;
setLoading(true);
try {
const response = await getHostAccess(hostId);
setAccessList(response.accessList || []);
} catch (error) {
console.error("Failed to load access list:", error);
setAccessList([]);
} finally {
setLoading(false);
}
}, [hostId]);
// Load host data
const loadHostData = React.useCallback(async () => {
if (!hostId) return;
try {
const host = await getSSHHostById(hostId);
setHostData(host);
} catch (error) {
console.error("Failed to load host data:", error);
setHostData(null);
}
}, [hostId]);
React.useEffect(() => {
loadRoles();
loadUsers();
if (!isNewHost) {
loadAccessList();
loadHostData();
}
}, [loadRoles, loadUsers, loadAccessList, loadHostData, isNewHost]);
// Load current user ID
React.useEffect(() => {
const fetchCurrentUser = async () => {
try {
const userInfo = await getUserInfo();
setCurrentUserId(userInfo.userId);
} catch (error) {
console.error("Failed to load current user:", error);
}
};
fetchCurrentUser();
}, []);
// Share host
const handleShare = async () => {
if (!hostId) {
toast.error(t("rbac.saveHostFirst"));
return;
}
if (shareType === "user" && !selectedUserId) {
toast.error(t("rbac.selectUser"));
return;
}
if (shareType === "role" && !selectedRoleId) {
toast.error(t("rbac.selectRole"));
return;
}
// Prevent sharing with self
if (shareType === "user" && selectedUserId === currentUserId) {
toast.error(t("rbac.cannotShareWithSelf"));
return;
}
try {
await shareHost(hostId, {
targetType: shareType,
targetUserId: shareType === "user" ? selectedUserId : undefined,
targetRoleId: shareType === "role" ? selectedRoleId : undefined,
permissionLevel,
durationHours: expiresInHours
? parseInt(expiresInHours, 10)
: undefined,
});
toast.success(t("rbac.sharedSuccessfully"));
setSelectedUserId("");
setSelectedRoleId(null);
setExpiresInHours("");
loadAccessList();
} catch (error) {
toast.error(t("rbac.failedToShare"));
}
};
// Revoke access
const handleRevoke = async (accessId: number) => {
if (!hostId) return;
const confirmed = await confirmWithToast({
title: t("rbac.confirmRevokeAccess"),
description: t("rbac.confirmRevokeAccessDescription"),
confirmText: t("common.revoke"),
cancelText: t("common.cancel"),
});
if (!confirmed) return;
try {
await revokeHostAccess(hostId, accessId);
toast.success(t("rbac.accessRevokedSuccessfully"));
loadAccessList();
} catch (error) {
toast.error(t("rbac.failedToRevokeAccess"));
}
};
// Format date
const formatDate = (dateString: string | null) => {
if (!dateString) return "-";
return new Date(dateString).toLocaleString();
};
// Check if expired
const isExpired = (expiresAt: string | null) => {
if (!expiresAt) return false;
return new Date(expiresAt) < new Date();
};
// Filter out current user from the users list
const availableUsers = React.useMemo(() => {
return users.filter((user) => user.id !== currentUserId);
}, [users, currentUserId]);
const selectedUser = availableUsers.find((u) => u.id === selectedUserId);
const selectedRole = roles.find((r) => r.id === selectedRoleId);
if (isNewHost) {
return (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("rbac.saveHostFirst")}</AlertTitle>
<AlertDescription>
{t("rbac.saveHostFirstDescription")}
</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-6">
{/* Credential Requirement Warning */}
{!hostData?.credentialId && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("rbac.credentialRequired")}</AlertTitle>
<AlertDescription>
{t("rbac.credentialRequiredDescription")}
</AlertDescription>
</Alert>
)}
{/* Share Form */}
{hostData?.credentialId && (
<>
<div className="space-y-4 border rounded-lg p-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Plus className="h-5 w-5" />
{t("rbac.shareHost")}
</h3>
{/* Share Type Selection */}
<Tabs
value={shareType}
onValueChange={(v) => setShareType(v as "user" | "role")}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="user" className="flex items-center gap-2">
<UserCircle className="h-4 w-4" />
{t("rbac.shareWithUser")}
</TabsTrigger>
<TabsTrigger value="role" className="flex items-center gap-2">
<Shield className="h-4 w-4" />
{t("rbac.shareWithRole")}
</TabsTrigger>
</TabsList>
<TabsContent value="user" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="user-select">{t("rbac.selectUser")}</Label>
<Popover open={userComboOpen} onOpenChange={setUserComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={userComboOpen}
className="w-full justify-between"
>
{selectedUser
? `${selectedUser.username}${selectedUser.is_admin ? " (Admin)" : ""}`
: t("rbac.selectUserPlaceholder")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder={t("rbac.searchUsers")} />
<CommandEmpty>{t("rbac.noUserFound")}</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto thin-scrollbar">
{availableUsers.map((user) => (
<CommandItem
key={user.id}
value={`${user.username} ${user.id}`}
onSelect={() => {
setSelectedUserId(user.id);
setUserComboOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedUserId === user.id
? "opacity-100"
: "opacity-0",
)}
/>
{user.username}
{user.is_admin ? " (Admin)" : ""}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
</TabsContent>
<TabsContent value="role" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="role-select">{t("rbac.selectRole")}</Label>
<Popover open={roleComboOpen} onOpenChange={setRoleComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={roleComboOpen}
className="w-full justify-between"
>
{selectedRole
? `${t(selectedRole.displayName)}${selectedRole.isSystem ? ` (${t("rbac.systemRole")})` : ""}`
: t("rbac.selectRolePlaceholder")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder={t("rbac.searchRoles")} />
<CommandEmpty>{t("rbac.noRoleFound")}</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto thin-scrollbar">
{roles.map((role) => (
<CommandItem
key={role.id}
value={`${role.displayName} ${role.name} ${role.id}`}
onSelect={() => {
setSelectedRoleId(role.id);
setRoleComboOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedRoleId === role.id
? "opacity-100"
: "opacity-0",
)}
/>
{t(role.displayName)}
{role.isSystem
? ` (${t("rbac.systemRole")})`
: ""}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
</TabsContent>
</Tabs>
{/* Permission Level */}
<div className="space-y-2">
<Label htmlFor="permission-level">
{t("rbac.permissionLevel")}
</Label>
<Select
value={permissionLevel || "use"}
onValueChange={(v) => setPermissionLevel(v || "use")}
>
<SelectTrigger id="permission-level">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PERMISSION_LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value}>
{t(level.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Expiration */}
<div className="space-y-2">
<Label htmlFor="expires-in">{t("rbac.durationHours")}</Label>
<Input
id="expires-in"
type="number"
value={expiresInHours}
onChange={(e) => {
const value = e.target.value;
if (value === "" || /^\d+$/.test(value)) {
setExpiresInHours(value);
}
}}
placeholder={t("rbac.neverExpires")}
min="1"
/>
</div>
<Button
type="button"
onClick={handleShare}
className="w-full"
disabled={!hostData?.credentialId}
>
<Plus className="h-4 w-4 mr-2" />
{t("rbac.share")}
</Button>
</div>
{/* Access List */}
<div className="space-y-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Users className="h-5 w-5" />
{t("rbac.accessList")}
</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("rbac.type")}</TableHead>
<TableHead>{t("rbac.target")}</TableHead>
<TableHead>{t("rbac.permissionLevel")}</TableHead>
<TableHead>{t("rbac.grantedBy")}</TableHead>
<TableHead>{t("rbac.expires")}</TableHead>
<TableHead>{t("rbac.accessCount")}</TableHead>
<TableHead className="text-right">
{t("common.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground"
>
{t("common.loading")}
</TableCell>
</TableRow>
) : accessList.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground"
>
{t("rbac.noAccessRecords")}
</TableCell>
</TableRow>
) : (
accessList.map((access) => (
<TableRow
key={access.id}
className={
isExpired(access.expiresAt) ? "opacity-50" : ""
}
>
<TableCell>
{access.targetType === "user" ? (
<Badge
variant="outline"
className="flex items-center gap-1 w-fit"
>
<UserCircle className="h-3 w-3" />
{t("rbac.user")}
</Badge>
) : (
<Badge
variant="outline"
className="flex items-center gap-1 w-fit"
>
<Shield className="h-3 w-3" />
{t("rbac.role")}
</Badge>
)}
</TableCell>
<TableCell>
{access.targetType === "user"
? access.username
: t(access.roleDisplayName || access.roleName || "")}
</TableCell>
<TableCell>
<Badge variant="secondary">
{access.permissionLevel}
</Badge>
</TableCell>
<TableCell>{access.grantedByUsername}</TableCell>
<TableCell>
{access.expiresAt ? (
<div className="flex items-center gap-2">
<Clock className="h-3 w-3" />
<span
className={
isExpired(access.expiresAt)
? "text-red-500"
: ""
}
>
{formatDate(access.expiresAt)}
{isExpired(access.expiresAt) && (
<span className="ml-2">
({t("rbac.expired")})
</span>
)}
</span>
</div>
) : (
t("rbac.never")
)}
</TableCell>
<TableCell>{access.accessCount}</TableCell>
<TableCell className="text-right">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => handleRevoke(access.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</>
)}
</div>
);
}

View File

@@ -1,8 +0,0 @@
export { CpuWidget } from "./CpuWidget";
export { MemoryWidget } from "./MemoryWidget";
export { DiskWidget } from "./DiskWidget";
export { NetworkWidget } from "./NetworkWidget";
export { UptimeWidget } from "./UptimeWidget";
export { ProcessesWidget } from "./ProcessesWidget";
export { SystemWidget } from "./SystemWidget";
export { LoginStatsWidget } from "./LoginStatsWidget";

View File

@@ -1,9 +1,9 @@
import React, { useEffect, useRef, useState, useMemo } from "react"; import React, { useEffect, useRef, useState, useMemo } from "react";
import { Terminal } from "@/ui/desktop/apps/terminal/Terminal.tsx"; import { Terminal } from "@/ui/desktop/apps/features/terminal/Terminal.tsx";
import { ServerStats as ServerView } from "@/ui/desktop/apps/server-stats/ServerStats.tsx"; import { ServerStats as ServerView } from "@/ui/desktop/apps/features/server-stats/ServerStats.tsx";
import { FileManager } from "@/ui/desktop/apps/file-manager/FileManager.tsx"; import { FileManager } from "@/ui/desktop/apps/features/file-manager/FileManager.tsx";
import { TunnelManager } from "@/ui/desktop/apps/tunnel/TunnelManager.tsx"; import { TunnelManager } from "@/ui/desktop/apps/features/tunnel/TunnelManager.tsx";
import { DockerManager } from "@/ui/desktop/apps/docker/DockerManager.tsx"; import { DockerManager } from "@/ui/desktop/apps/features/docker/DockerManager.tsx";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { import {
ResizablePanelGroup, ResizablePanelGroup,

View File

@@ -8,7 +8,7 @@ import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TabDropdown } from "@/ui/desktop/navigation/tabs/TabDropdown.tsx"; import { TabDropdown } from "@/ui/desktop/navigation/tabs/TabDropdown.tsx";
import { SSHToolsSidebar } from "@/ui/desktop/apps/tools/SSHToolsSidebar.tsx"; import { SSHToolsSidebar } from "@/ui/desktop/apps/tools/SSHToolsSidebar.tsx";
import { useCommandHistory } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx"; import { useCommandHistory } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
interface TabData { interface TabData {
id: number; id: number;