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",
"noCustomRolesToAssign": "No custom roles available. System roles are auto-assigned.",
"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 using credential 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.",
"credentialRequired": "Credential is required when sharing a host",
"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.",
"auditLogs": "Audit Logs",
"viewAuditLogs": "View Audit Logs",
"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 { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.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 {
TabProvider,
useTabs,
} from "@/ui/desktop/navigation/tabs/TabContext.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 { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
import { Toaster } from "@/components/ui/sonner.tsx";
@@ -63,8 +63,10 @@ function AppContent() {
const now = Date.now();
if (now - lastAltPressTime.current < 300) {
// Use setTheme to properly update React state (not just DOM class)
const currentIsDark = theme === "dark" ||
(theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
const currentIsDark =
theme === "dark" ||
(theme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
const newTheme = currentIsDark ? "light" : "dark";
setTheme(newTheme);
console.log("[DEBUG] Theme toggled:", newTheme);

View File

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

View File

@@ -14,7 +14,7 @@ import {
import { Card, CardContent } from "@/components/ui/card.tsx";
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
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 { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
import { useTranslation } from "react-i18next";

View File

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

View File

@@ -9,7 +9,7 @@ import {
} from "@/components/ui/tabs.tsx";
import { ArrowLeft } from "lucide-react";
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 { ContainerStats } from "./ContainerStats.tsx";
import { ConsoleTerminal } from "./ConsoleTerminal.tsx";

View File

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

View File

@@ -7,7 +7,7 @@ import {
} from "@/components/ui/card.tsx";
import { Progress } from "@/components/ui/progress.tsx";
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 { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
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 { Download, RefreshCw, Filter } from "lucide-react";
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 { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";

View File

@@ -1,23 +1,26 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import { FileManagerGrid } from "./FileManagerGrid";
import { FileManagerSidebar } from "./FileManagerSidebar";
import { FileManagerContextMenu } from "./FileManagerContextMenu";
import { useFileSelection } from "./hooks/useFileSelection";
import { useDragAndDrop } from "./hooks/useDragAndDrop";
import { WindowManager, useWindowManager } from "./components/WindowManager";
import { FileWindow } from "./components/FileWindow";
import { DiffWindow } from "./components/DiffWindow";
import { useDragToDesktop } from "../../../hooks/useDragToDesktop";
import { useDragToSystemDesktop } from "../../../hooks/useDragToSystemDesktop";
import { FileManagerGrid } from "./FileManagerGrid.tsx";
import { FileManagerSidebar } from "./FileManagerSidebar.tsx";
import { FileManagerContextMenu } from "./FileManagerContextMenu.tsx";
import { useFileSelection } from "./hooks/useFileSelection.ts";
import { useDragAndDrop } from "./hooks/useDragAndDrop.ts";
import {
WindowManager,
useWindowManager,
} from "./components/WindowManager.tsx";
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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
import { PermissionsDialog } from "./components/PermissionsDialog";
import { CompressDialog } from "./components/CompressDialog";
import { PermissionsDialog } from "./components/PermissionsDialog.tsx";
import { CompressDialog } from "./components/CompressDialog.tsx";
import {
Upload,
FolderPlus,
@@ -27,7 +30,7 @@ import {
Grid3X3,
List,
} from "lucide-react";
import { TerminalWindow } from "./components/TerminalWindow";
import { TerminalWindow } from "./components/TerminalWindow.tsx";
import type { SSHHost, FileItem } from "../../../types/index.js";
import {
listSSHFiles,
@@ -55,7 +58,7 @@ import {
extractSSHArchive,
compressSSHFiles,
} from "@/ui/main-axios.ts";
import type { SidebarItem } from "./FileManagerSidebar";
import type { SidebarItem } from "./FileManagerSidebar.tsx";
interface FileManagerProps {
initialHost?: SSHHost | null;
@@ -586,7 +589,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
error.message?.includes("established")
) {
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 {
toast.error(t("fileManager.failedToUploadFile"));
@@ -633,7 +640,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
error.message?.includes("established")
) {
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 {
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 items-center justify-between p-3">
<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">
{currentHost.ip}:{currentHost.port}
</span>

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils.ts";
import {
ChevronRight,
ChevronDown,
@@ -11,7 +11,7 @@ import {
FolderOpen,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SSHHost } from "@/types/index";
import type { SSHHost } from "@/types";
import {
getRecentFiles,
getPinnedFiles,
@@ -474,9 +474,7 @@ export function FileManagerSidebar({
)}
<div
className={cn(
hasQuickAccessItems && "pt-4 border-t border-edge",
)}
className={cn(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">
<Folder className="w-3 h-3" />

View File

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

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from "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 { useTranslation } from "react-i18next";
import {
@@ -16,8 +16,8 @@ import {
downloadSSHFile,
getSSHStatus,
connectSSH,
} from "@/ui/main-axios";
import type { FileItem, SSHHost } from "@/types/index";
} from "@/ui/main-axios.ts";
import type { FileItem, SSHHost } from "@/types";
interface DiffViewerProps {
file1: FileItem;

View File

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

View File

@@ -1,5 +1,5 @@
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 { useTranslation } from "react-i18next";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
import type { TerminalTheme } from "@/constants/terminal-themes";
import { TERMINAL_THEMES, TERMINAL_FONTS } from "@/constants/terminal-themes";
import { useTheme } from "@/components/theme-provider";
import type { TerminalTheme } from "@/constants/terminal-themes.ts";
import {
TERMINAL_THEMES,
TERMINAL_FONTS,
} from "@/constants/terminal-themes.ts";
import { useTheme } from "@/components/theme-provider.tsx";
interface TerminalPreviewProps {
theme: string;
@@ -24,11 +27,14 @@ export function TerminalPreview({
const { theme: appTheme } = useTheme();
// Resolve "termix" to termixDark or termixLight based on app theme
const resolvedTheme = theme === "termix"
? (appTheme === "dark" || (appTheme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches)
? "termixDark"
: "termixLight")
: theme;
const resolvedTheme =
theme === "termix"
? appTheme === "dark" ||
(appTheme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
? "termixDark"
: "termixLight"
: theme;
return (
<div className="border border-input rounded-md overflow-hidden">
@@ -41,8 +47,12 @@ export function TerminalPreview({
TERMINAL_FONTS[0].fallback,
letterSpacing: `${letterSpacing}px`,
lineHeight,
background: TERMINAL_THEMES[resolvedTheme]?.colors.background || "var(--bg-base)",
color: TERMINAL_THEMES[resolvedTheme]?.colors.foreground || "var(--foreground)",
background:
TERMINAL_THEMES[resolvedTheme]?.colors.background ||
"var(--bg-base)",
color:
TERMINAL_THEMES[resolvedTheme]?.colors.foreground ||
"var(--foreground)",
}}
>
<div>
@@ -50,7 +60,9 @@ export function TerminalPreview({
user@termix
</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>
</div>
<div>
@@ -81,7 +93,9 @@ export function TerminalPreview({
user@termix
</span>
<span>:</span>
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>~</span>
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>
~
</span>
<span>$ </span>
<span
className="inline-block"
@@ -93,7 +107,8 @@ export function TerminalPreview({
: cursorStyle === "bar"
? `${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",
verticalAlign:
cursorStyle === "underline" ? "bottom" : "text-bottom",

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback } from "react";
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 {
getSSHHosts,
getTunnelStatuses,

View File

@@ -1,7 +1,7 @@
import React from "react";
import { useSidebar } from "@/components/ui/sidebar.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";
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,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
} from "@/components/ui/dialog.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Label } from "@/components/ui/label.tsx";
import { useTranslation } from "react-i18next";
import {
Folder,

View File

@@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Button } from "@/components/ui/button.tsx";
import {
Form,
FormControl,
@@ -10,13 +10,18 @@ import {
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Alert, AlertDescription } from "@/components/ui/alert";
} from "@/components/ui/form.tsx";
import { Input } from "@/components/ui/input.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx";
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import {
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 { toast } from "sonner";
import {
@@ -28,18 +33,18 @@ import {
detectPublicKeyType,
generatePublicKeyFromPrivate,
generateKeyPair,
} from "@/ui/main-axios";
} from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark";
import { githubLight } from "@uiw/codemirror-theme-github";
import { EditorView } from "@codemirror/view";
import { useTheme } from "@/components/theme-provider";
import { useTheme } from "@/components/theme-provider.tsx";
import type {
Credential,
CredentialEditorProps,
CredentialData,
} from "../../../../types/index.js";
} from "../../../../../types";
export function CredentialEditor({
editingCredential,
@@ -635,7 +640,7 @@ export function CredentialEditor({
)}
<input
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}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
@@ -950,7 +955,7 @@ export function CredentialEditor({
"placeholders.pastePrivateKey",
)}
theme={editorTheme}
className="border border-input rounded-md"
className="border border-input rounded-md overflow-hidden"
minHeight="120px"
basicSetup={{
lineNumbers: true,
@@ -1113,7 +1118,7 @@ export function CredentialEditor({
}}
placeholder={t("placeholders.pastePublicKey")}
theme={editorTheme}
className="border border-input rounded-md"
className="border border-input rounded-md overflow-hidden"
minHeight="120px"
basicSetup={{
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 { getCredentials } from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
import type { Credential } from "../../../../types";
import type { Credential } from "../../../../../types";
interface CredentialSelectorProps {
value?: number | null;

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
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 {
Tabs,
TabsContent,
@@ -7,9 +7,9 @@ import {
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { HostManagerEditor } from "@/ui/desktop/apps/host-manager/HostManagerEditor.tsx";
import { CredentialsManager } from "@/ui/desktop/apps/credentials/CredentialsManager.tsx";
import { CredentialEditor } from "@/ui/desktop/apps/credentials/CredentialEditor.tsx";
import { HostManagerEditor } from "@/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx";
import { CredentialsManager } from "@/ui/desktop/apps/host-manager/credentials/CredentialsManager.tsx";
import { CredentialEditor } from "@/ui/desktop/apps/host-manager/credentials/CredentialEditor.tsx";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { useTranslation } from "react-i18next";
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"
>
<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")}
</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.id
? t("hosts.editHost")
@@ -128,10 +134,16 @@ export function HostManager({
: t("hosts.addHost")}
</TabsTrigger>
<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")}
</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
? t("credentials.editCredential")
: t("credentials.addCredential")}

View File

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

View File

@@ -68,10 +68,10 @@ import type {
SSHHost,
SSHFolder,
SSHManagerHostViewerProps,
} from "../../../../types/index.js";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
import { FolderEditDialog } from "./components/FolderEditDialog";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
} from "../../../../../types";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets.ts";
import { FolderEditDialog } from "../components/FolderEditDialog.tsx";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
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 { Terminal } from "@/ui/desktop/apps/terminal/Terminal.tsx";
import { ServerStats as ServerView } from "@/ui/desktop/apps/server-stats/ServerStats.tsx";
import { FileManager } from "@/ui/desktop/apps/file-manager/FileManager.tsx";
import { TunnelManager } from "@/ui/desktop/apps/tunnel/TunnelManager.tsx";
import { DockerManager } from "@/ui/desktop/apps/docker/DockerManager.tsx";
import { Terminal } from "@/ui/desktop/apps/features/terminal/Terminal.tsx";
import { ServerStats as ServerView } from "@/ui/desktop/apps/features/server-stats/ServerStats.tsx";
import { FileManager } from "@/ui/desktop/apps/features/file-manager/FileManager.tsx";
import { TunnelManager } from "@/ui/desktop/apps/features/tunnel/TunnelManager.tsx";
import { DockerManager } from "@/ui/desktop/apps/features/docker/DockerManager.tsx";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import {
ResizablePanelGroup,

View File

@@ -8,7 +8,7 @@ import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { useTranslation } from "react-i18next";
import { TabDropdown } from "@/ui/desktop/navigation/tabs/TabDropdown.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 {
id: number;