chore: improve organization and made a few styling changes in host manager
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
@@ -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";
|
||||
@@ -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 {
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
@@ -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" />
|
||||
@@ -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 {
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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%",
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type WidgetType,
|
||||
type StatsConfig,
|
||||
DEFAULT_STATS_CONFIG,
|
||||
} from "@/types/stats-widgets";
|
||||
} from "@/types/stats-widgets.ts";
|
||||
import {
|
||||
CpuWidget,
|
||||
MemoryWidget,
|
||||
@@ -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";
|
||||
@@ -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(
|
||||
@@ -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",
|
||||
@@ -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,
|
||||
@@ -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 {
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
@@ -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>
|
||||
@@ -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")}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
606
src/ui/desktop/apps/host-manager/hosts/HostSharingTab.tsx
Normal file
606
src/ui/desktop/apps/host-manager/hosts/HostSharingTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user