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",
|
"cannotShareWithSelf": "Cannot share host with yourself",
|
||||||
"noCustomRolesToAssign": "No custom roles available. System roles are auto-assigned.",
|
"noCustomRolesToAssign": "No custom roles available. System roles are auto-assigned.",
|
||||||
"credentialSharingWarning": "Credential Authentication Not Supported for Sharing",
|
"credentialSharingWarning": "Credential Authentication Not Supported for Sharing",
|
||||||
"credentialSharingWarningDescription": "This host uses credential-based authentication. Shared users will not be able to connect because credentials are encrypted per-user and cannot be shared. Please use password or key-based authentication for hosts you intend to share.",
|
"credentialRequired": "Credential is required when sharing a host",
|
||||||
"credentialRequired": "Credential is required when using credential authentication",
|
"credentialRequiredDescription": "This host does not use credential-based authentication. In order to share hosts, due to per-user-encryption, the host must use credential based authentication.",
|
||||||
"credentialRequiredDescription": "This host uses credential-based authentication. Shared users will not be able to connect because credentials are encrypted per-user and cannot be shared. Please use password or key-based authentication for hosts you intend to share.",
|
|
||||||
"auditLogs": "Audit Logs",
|
"auditLogs": "Audit Logs",
|
||||||
"viewAuditLogs": "View Audit Logs",
|
"viewAuditLogs": "View Audit Logs",
|
||||||
"action": "Action",
|
"action": "Action",
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import React, { useState, useEffect, useCallback, useRef } from "react";
|
|||||||
import { LeftSidebar } from "@/ui/desktop/navigation/LeftSidebar.tsx";
|
import { LeftSidebar } from "@/ui/desktop/navigation/LeftSidebar.tsx";
|
||||||
import { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.tsx";
|
import { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.tsx";
|
||||||
import { AppView } from "@/ui/desktop/navigation/AppView.tsx";
|
import { AppView } from "@/ui/desktop/navigation/AppView.tsx";
|
||||||
import { HostManager } from "@/ui/desktop/apps/host-manager/HostManager.tsx";
|
import { HostManager } from "@/ui/desktop/apps/host-manager/hosts/HostManager.tsx";
|
||||||
import {
|
import {
|
||||||
TabProvider,
|
TabProvider,
|
||||||
useTabs,
|
useTabs,
|
||||||
} from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
} from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||||
import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx";
|
import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx";
|
||||||
import { CommandHistoryProvider } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx";
|
import { CommandHistoryProvider } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
|
||||||
import { AdminSettings } from "@/ui/desktop/admin/AdminSettings.tsx";
|
import { AdminSettings } from "@/ui/desktop/admin/AdminSettings.tsx";
|
||||||
import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
|
import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
|
||||||
import { Toaster } from "@/components/ui/sonner.tsx";
|
import { Toaster } from "@/components/ui/sonner.tsx";
|
||||||
@@ -63,8 +63,10 @@ function AppContent() {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastAltPressTime.current < 300) {
|
if (now - lastAltPressTime.current < 300) {
|
||||||
// Use setTheme to properly update React state (not just DOM class)
|
// Use setTheme to properly update React state (not just DOM class)
|
||||||
const currentIsDark = theme === "dark" ||
|
const currentIsDark =
|
||||||
(theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
theme === "dark" ||
|
||||||
|
(theme === "system" &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
const newTheme = currentIsDark ? "light" : "dark";
|
const newTheme = currentIsDark ? "light" : "dark";
|
||||||
setTheme(newTheme);
|
setTheme(newTheme);
|
||||||
console.log("[DEBUG] Theme toggled:", newTheme);
|
console.log("[DEBUG] Theme toggled:", newTheme);
|
||||||
|
|||||||
@@ -9,11 +9,7 @@ import {
|
|||||||
} from "@/components/ui/tabs.tsx";
|
} from "@/components/ui/tabs.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type {
|
import type { SSHHost, DockerContainer, DockerValidation } from "@/types";
|
||||||
SSHHost,
|
|
||||||
DockerContainer,
|
|
||||||
DockerValidation,
|
|
||||||
} from "@/types/index.js";
|
|
||||||
import {
|
import {
|
||||||
connectDockerSession,
|
connectDockerSession,
|
||||||
disconnectDockerSession,
|
disconnectDockerSession,
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { Card, CardContent } from "@/components/ui/card.tsx";
|
import { Card, CardContent } from "@/components/ui/card.tsx";
|
||||||
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
|
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { SSHHost } from "@/types/index.js";
|
import type { SSHHost } from "@/types";
|
||||||
import { getCookie, isElectron } from "@/ui/main-axios.ts";
|
import { getCookie, isElectron } from "@/ui/main-axios.ts";
|
||||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { DockerContainer } from "@/types/index.js";
|
import type { DockerContainer } from "@/types";
|
||||||
import {
|
import {
|
||||||
startDockerContainer,
|
startDockerContainer,
|
||||||
stopDockerContainer,
|
stopDockerContainer,
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "@/components/ui/tabs.tsx";
|
} from "@/components/ui/tabs.tsx";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { DockerContainer, SSHHost } from "@/types/index.js";
|
import type { DockerContainer, SSHHost } from "@/types";
|
||||||
import { LogViewer } from "./LogViewer.tsx";
|
import { LogViewer } from "./LogViewer.tsx";
|
||||||
import { ContainerStats } from "./ContainerStats.tsx";
|
import { ContainerStats } from "./ContainerStats.tsx";
|
||||||
import { ConsoleTerminal } from "./ConsoleTerminal.tsx";
|
import { ConsoleTerminal } from "./ConsoleTerminal.tsx";
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "@/components/ui/select.tsx";
|
} from "@/components/ui/select.tsx";
|
||||||
import { Search, Filter } from "lucide-react";
|
import { Search, Filter } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { DockerContainer } from "@/types/index.js";
|
import type { DockerContainer } from "@/types";
|
||||||
import { ContainerCard } from "./ContainerCard.tsx";
|
import { ContainerCard } from "./ContainerCard.tsx";
|
||||||
|
|
||||||
interface ContainerListProps {
|
interface ContainerListProps {
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from "@/components/ui/card.tsx";
|
} from "@/components/ui/card.tsx";
|
||||||
import { Progress } from "@/components/ui/progress.tsx";
|
import { Progress } from "@/components/ui/progress.tsx";
|
||||||
import { Cpu, MemoryStick, Network, HardDrive, Activity } from "lucide-react";
|
import { Cpu, MemoryStick, Network, HardDrive, Activity } from "lucide-react";
|
||||||
import type { DockerStats } from "@/types/index.js";
|
import type { DockerStats } from "@/types";
|
||||||
import { getContainerStats } from "@/ui/main-axios.ts";
|
import { getContainerStats } from "@/ui/main-axios.ts";
|
||||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -12,7 +12,7 @@ import { Switch } from "@/components/ui/switch.tsx";
|
|||||||
import { Label } from "@/components/ui/label.tsx";
|
import { Label } from "@/components/ui/label.tsx";
|
||||||
import { Download, RefreshCw, Filter } from "lucide-react";
|
import { Download, RefreshCw, Filter } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { DockerLogOptions } from "@/types/index.js";
|
import type { DockerLogOptions } from "@/types";
|
||||||
import { getContainerLogs, downloadContainerLogs } from "@/ui/main-axios.ts";
|
import { getContainerLogs, downloadContainerLogs } from "@/ui/main-axios.ts";
|
||||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||||
|
|
||||||
@@ -1,23 +1,26 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { FileManagerGrid } from "./FileManagerGrid";
|
import { FileManagerGrid } from "./FileManagerGrid.tsx";
|
||||||
import { FileManagerSidebar } from "./FileManagerSidebar";
|
import { FileManagerSidebar } from "./FileManagerSidebar.tsx";
|
||||||
import { FileManagerContextMenu } from "./FileManagerContextMenu";
|
import { FileManagerContextMenu } from "./FileManagerContextMenu.tsx";
|
||||||
import { useFileSelection } from "./hooks/useFileSelection";
|
import { useFileSelection } from "./hooks/useFileSelection.ts";
|
||||||
import { useDragAndDrop } from "./hooks/useDragAndDrop";
|
import { useDragAndDrop } from "./hooks/useDragAndDrop.ts";
|
||||||
import { WindowManager, useWindowManager } from "./components/WindowManager";
|
import {
|
||||||
import { FileWindow } from "./components/FileWindow";
|
WindowManager,
|
||||||
import { DiffWindow } from "./components/DiffWindow";
|
useWindowManager,
|
||||||
import { useDragToDesktop } from "../../../hooks/useDragToDesktop";
|
} from "./components/WindowManager.tsx";
|
||||||
import { useDragToSystemDesktop } from "../../../hooks/useDragToSystemDesktop";
|
import { FileWindow } from "./components/FileWindow.tsx";
|
||||||
|
import { DiffWindow } from "./components/DiffWindow.tsx";
|
||||||
|
import { useDragToDesktop } from "../../../../hooks/useDragToDesktop.ts";
|
||||||
|
import { useDragToSystemDesktop } from "../../../../hooks/useDragToSystemDesktop.ts";
|
||||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
|
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
|
||||||
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
|
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
|
||||||
import { PermissionsDialog } from "./components/PermissionsDialog";
|
import { PermissionsDialog } from "./components/PermissionsDialog.tsx";
|
||||||
import { CompressDialog } from "./components/CompressDialog";
|
import { CompressDialog } from "./components/CompressDialog.tsx";
|
||||||
import {
|
import {
|
||||||
Upload,
|
Upload,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
@@ -27,7 +30,7 @@ import {
|
|||||||
Grid3X3,
|
Grid3X3,
|
||||||
List,
|
List,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { TerminalWindow } from "./components/TerminalWindow";
|
import { TerminalWindow } from "./components/TerminalWindow.tsx";
|
||||||
import type { SSHHost, FileItem } from "../../../types/index.js";
|
import type { SSHHost, FileItem } from "../../../types/index.js";
|
||||||
import {
|
import {
|
||||||
listSSHFiles,
|
listSSHFiles,
|
||||||
@@ -55,7 +58,7 @@ import {
|
|||||||
extractSSHArchive,
|
extractSSHArchive,
|
||||||
compressSSHFiles,
|
compressSSHFiles,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
import type { SidebarItem } from "./FileManagerSidebar";
|
import type { SidebarItem } from "./FileManagerSidebar.tsx";
|
||||||
|
|
||||||
interface FileManagerProps {
|
interface FileManagerProps {
|
||||||
initialHost?: SSHHost | null;
|
initialHost?: SSHHost | null;
|
||||||
@@ -586,7 +589,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
error.message?.includes("established")
|
error.message?.includes("established")
|
||||||
) {
|
) {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("fileManager.sshConnectionFailed", { name: currentHost?.name, ip: currentHost?.ip, port: currentHost?.port }),
|
t("fileManager.sshConnectionFailed", {
|
||||||
|
name: currentHost?.name,
|
||||||
|
ip: currentHost?.ip,
|
||||||
|
port: currentHost?.port,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(t("fileManager.failedToUploadFile"));
|
toast.error(t("fileManager.failedToUploadFile"));
|
||||||
@@ -633,7 +640,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
error.message?.includes("established")
|
error.message?.includes("established")
|
||||||
) {
|
) {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("fileManager.sshConnectionFailed", { name: currentHost?.name, ip: currentHost?.ip, port: currentHost?.port }),
|
t("fileManager.sshConnectionFailed", {
|
||||||
|
name: currentHost?.name,
|
||||||
|
ip: currentHost?.ip,
|
||||||
|
port: currentHost?.port,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(t("fileManager.failedToDownloadFile"));
|
toast.error(t("fileManager.failedToDownloadFile"));
|
||||||
@@ -1943,7 +1954,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
<div className="flex-shrink-0 border-b border-edge">
|
<div className="flex-shrink-0 border-b border-edge">
|
||||||
<div className="flex items-center justify-between p-3">
|
<div className="flex items-center justify-between p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="font-semibold text-foreground">{currentHost.name}</h2>
|
<h2 className="font-semibold text-foreground">
|
||||||
|
{currentHost.name}
|
||||||
|
</h2>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{currentHost.ip}:{currentHost.port}
|
{currentHost.ip}:{currentHost.port}
|
||||||
</span>
|
</span>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils.ts";
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
Edit3,
|
Edit3,
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
FileArchive,
|
FileArchive,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
import { Kbd, KbdGroup } from "@/components/ui/kbd.tsx";
|
||||||
|
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils.ts";
|
||||||
import {
|
import {
|
||||||
Folder,
|
Folder,
|
||||||
File,
|
File,
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils.ts";
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { SSHHost } from "@/types/index";
|
import type { SSHHost } from "@/types";
|
||||||
import {
|
import {
|
||||||
getRecentFiles,
|
getRecentFiles,
|
||||||
getPinnedFiles,
|
getPinnedFiles,
|
||||||
@@ -474,9 +474,7 @@ export function FileManagerSidebar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(hasQuickAccessItems && "pt-4 border-t border-edge")}
|
||||||
hasQuickAccessItems && "pt-4 border-t border-edge",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<div className="flex items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
<Folder className="w-3 h-3" />
|
<Folder className="w-3 h-3" />
|
||||||
@@ -6,17 +6,17 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog.tsx";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label.tsx";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface CompressDialogProps {
|
interface CompressDialogProps {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { DiffEditor } from "@monaco-editor/react";
|
import { DiffEditor } from "@monaco-editor/react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
downloadSSHFile,
|
downloadSSHFile,
|
||||||
getSSHStatus,
|
getSSHStatus,
|
||||||
connectSSH,
|
connectSSH,
|
||||||
} from "@/ui/main-axios";
|
} from "@/ui/main-axios.ts";
|
||||||
import type { FileItem, SSHHost } from "@/types/index";
|
import type { FileItem, SSHHost } from "@/types";
|
||||||
|
|
||||||
interface DiffViewerProps {
|
interface DiffViewerProps {
|
||||||
file1: FileItem;
|
file1: FileItem;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { DraggableWindow } from "./DraggableWindow";
|
import { DraggableWindow } from "./DraggableWindow.tsx";
|
||||||
import { DiffViewer } from "./DiffViewer";
|
import { DiffViewer } from "./DiffViewer.tsx";
|
||||||
import { useWindowManager } from "./WindowManager";
|
import { useWindowManager } from "./WindowManager.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { FileItem, SSHHost } from "../../../../types/index.js";
|
import type { FileItem, SSHHost } from "../../../../types/index.js";
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils.ts";
|
||||||
import { Minus, X, Maximize2, Minimize2 } from "lucide-react";
|
import { Minus, X, Maximize2, Minimize2 } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
@@ -45,7 +45,7 @@ import {
|
|||||||
SiMysql,
|
SiMysql,
|
||||||
SiDocker,
|
SiDocker,
|
||||||
} from "react-icons/si";
|
} from "react-icons/si";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import CodeMirror from "@uiw/react-codemirror";
|
import CodeMirror from "@uiw/react-codemirror";
|
||||||
import { oneDark } from "@codemirror/theme-one-dark";
|
import { oneDark } from "@codemirror/theme-one-dark";
|
||||||
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
|
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
|
||||||
@@ -813,7 +813,8 @@ export function FileViewer({
|
|||||||
".cm-scroller": {
|
".cm-scroller": {
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
scrollbarWidth: "thin",
|
scrollbarWidth: "thin",
|
||||||
scrollbarColor: "var(--scrollbar-thumb) var(--scrollbar-track)",
|
scrollbarColor:
|
||||||
|
"var(--scrollbar-thumb) var(--scrollbar-track)",
|
||||||
},
|
},
|
||||||
".cm-editor": {
|
".cm-editor": {
|
||||||
height: "100%",
|
height: "100%",
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { DraggableWindow } from "./DraggableWindow";
|
import { DraggableWindow } from "./DraggableWindow.tsx";
|
||||||
import { FileViewer } from "./FileViewer";
|
import { FileViewer } from "./FileViewer.tsx";
|
||||||
import { useWindowManager } from "./WindowManager";
|
import { useWindowManager } from "./WindowManager.tsx";
|
||||||
import {
|
import {
|
||||||
downloadSSHFile,
|
downloadSSHFile,
|
||||||
readSSHFile,
|
readSSHFile,
|
||||||
writeSSHFile,
|
writeSSHFile,
|
||||||
getSSHStatus,
|
getSSHStatus,
|
||||||
connectSSH,
|
connectSSH,
|
||||||
} from "@/ui/main-axios";
|
} from "@/ui/main-axios.ts";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -6,11 +6,11 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog.tsx";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label.tsx";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox.tsx";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Shield } from "lucide-react";
|
import { Shield } from "lucide-react";
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { DraggableWindow } from "./DraggableWindow";
|
import { DraggableWindow } from "./DraggableWindow.tsx";
|
||||||
import { Terminal } from "@/ui/desktop/apps/terminal/Terminal";
|
import { Terminal } from "@/ui/desktop/apps/features/terminal/Terminal.tsx";
|
||||||
import { useWindowManager } from "./WindowManager";
|
import { useWindowManager } from "./WindowManager.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface SSHHost {
|
interface SSHHost {
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
type WidgetType,
|
type WidgetType,
|
||||||
type StatsConfig,
|
type StatsConfig,
|
||||||
DEFAULT_STATS_CONFIG,
|
DEFAULT_STATS_CONFIG,
|
||||||
} from "@/types/stats-widgets";
|
} from "@/types/stats-widgets.ts";
|
||||||
import {
|
import {
|
||||||
CpuWidget,
|
CpuWidget,
|
||||||
MemoryWidget,
|
MemoryWidget,
|
||||||
@@ -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,
|
TERMINAL_THEMES,
|
||||||
DEFAULT_TERMINAL_CONFIG,
|
DEFAULT_TERMINAL_CONFIG,
|
||||||
TERMINAL_FONTS,
|
TERMINAL_FONTS,
|
||||||
} from "@/constants/terminal-themes";
|
} from "@/constants/terminal-themes.ts";
|
||||||
import type { TerminalConfig } from "@/types";
|
import type { TerminalConfig } from "@/types";
|
||||||
import { useTheme } from "@/components/theme-provider";
|
import { useTheme } from "@/components/theme-provider.tsx";
|
||||||
import { useCommandTracker } from "@/ui/hooks/useCommandTracker";
|
import { useCommandTracker } from "@/ui/hooks/useCommandTracker.ts";
|
||||||
import { highlightTerminalOutput } from "@/lib/terminal-syntax-highlighter.ts";
|
import { highlightTerminalOutput } from "@/lib/terminal-syntax-highlighter.ts";
|
||||||
import { useCommandHistory as useCommandHistoryHook } from "@/ui/hooks/useCommandHistory";
|
import { useCommandHistory as useCommandHistoryHook } from "@/ui/hooks/useCommandHistory.ts";
|
||||||
import { useCommandHistory } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx";
|
import { useCommandHistory } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
|
||||||
import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx";
|
import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx";
|
||||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||||
@@ -101,8 +101,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig };
|
const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig };
|
||||||
|
|
||||||
// Auto-switch terminal theme based on app theme when using "termix" (default)
|
// Auto-switch terminal theme based on app theme when using "termix" (default)
|
||||||
const isDarkMode = appTheme === "dark" ||
|
const isDarkMode =
|
||||||
(appTheme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
appTheme === "dark" ||
|
||||||
|
(appTheme === "system" &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
|
||||||
let themeColors;
|
let themeColors;
|
||||||
if (config.theme === "termix") {
|
if (config.theme === "termix") {
|
||||||
@@ -111,7 +113,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
? TERMINAL_THEMES.termixDark.colors
|
? TERMINAL_THEMES.termixDark.colors
|
||||||
: TERMINAL_THEMES.termixLight.colors;
|
: TERMINAL_THEMES.termixLight.colors;
|
||||||
} else {
|
} else {
|
||||||
themeColors = TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termixDark.colors;
|
themeColors =
|
||||||
|
TERMINAL_THEMES[config.theme]?.colors ||
|
||||||
|
TERMINAL_THEMES.termixDark.colors;
|
||||||
}
|
}
|
||||||
const backgroundColor = themeColors.background;
|
const backgroundColor = themeColors.background;
|
||||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
@@ -698,7 +702,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
!sudoPromptShownRef.current
|
!sudoPromptShownRef.current
|
||||||
) {
|
) {
|
||||||
sudoPromptShownRef.current = true;
|
sudoPromptShownRef.current = true;
|
||||||
confirmWithToast(t("terminal.sudoPasswordPopupTitle"), async () => {
|
confirmWithToast(
|
||||||
|
t("terminal.sudoPasswordPopupTitle"),
|
||||||
|
async () => {
|
||||||
if (
|
if (
|
||||||
webSocketRef.current &&
|
webSocketRef.current &&
|
||||||
webSocketRef.current.readyState === WebSocket.OPEN
|
webSocketRef.current.readyState === WebSocket.OPEN
|
||||||
@@ -1067,7 +1073,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
? TERMINAL_THEMES.termixDark.colors
|
? TERMINAL_THEMES.termixDark.colors
|
||||||
: TERMINAL_THEMES.termixLight.colors;
|
: TERMINAL_THEMES.termixLight.colors;
|
||||||
} else {
|
} else {
|
||||||
themeColors = TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termixDark.colors;
|
themeColors =
|
||||||
|
TERMINAL_THEMES[config.theme]?.colors ||
|
||||||
|
TERMINAL_THEMES.termixDark.colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fontConfig = TERMINAL_FONTS.find(
|
const fontConfig = TERMINAL_FONTS.find(
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { TerminalTheme } from "@/constants/terminal-themes";
|
import type { TerminalTheme } from "@/constants/terminal-themes.ts";
|
||||||
import { TERMINAL_THEMES, TERMINAL_FONTS } from "@/constants/terminal-themes";
|
import {
|
||||||
import { useTheme } from "@/components/theme-provider";
|
TERMINAL_THEMES,
|
||||||
|
TERMINAL_FONTS,
|
||||||
|
} from "@/constants/terminal-themes.ts";
|
||||||
|
import { useTheme } from "@/components/theme-provider.tsx";
|
||||||
|
|
||||||
interface TerminalPreviewProps {
|
interface TerminalPreviewProps {
|
||||||
theme: string;
|
theme: string;
|
||||||
@@ -24,10 +27,13 @@ export function TerminalPreview({
|
|||||||
const { theme: appTheme } = useTheme();
|
const { theme: appTheme } = useTheme();
|
||||||
|
|
||||||
// Resolve "termix" to termixDark or termixLight based on app theme
|
// Resolve "termix" to termixDark or termixLight based on app theme
|
||||||
const resolvedTheme = theme === "termix"
|
const resolvedTheme =
|
||||||
? (appTheme === "dark" || (appTheme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches)
|
theme === "termix"
|
||||||
|
? appTheme === "dark" ||
|
||||||
|
(appTheme === "system" &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches)
|
||||||
? "termixDark"
|
? "termixDark"
|
||||||
: "termixLight")
|
: "termixLight"
|
||||||
: theme;
|
: theme;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -41,8 +47,12 @@ export function TerminalPreview({
|
|||||||
TERMINAL_FONTS[0].fallback,
|
TERMINAL_FONTS[0].fallback,
|
||||||
letterSpacing: `${letterSpacing}px`,
|
letterSpacing: `${letterSpacing}px`,
|
||||||
lineHeight,
|
lineHeight,
|
||||||
background: TERMINAL_THEMES[resolvedTheme]?.colors.background || "var(--bg-base)",
|
background:
|
||||||
color: TERMINAL_THEMES[resolvedTheme]?.colors.foreground || "var(--foreground)",
|
TERMINAL_THEMES[resolvedTheme]?.colors.background ||
|
||||||
|
"var(--bg-base)",
|
||||||
|
color:
|
||||||
|
TERMINAL_THEMES[resolvedTheme]?.colors.foreground ||
|
||||||
|
"var(--foreground)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
@@ -50,7 +60,9 @@ export function TerminalPreview({
|
|||||||
user@termix
|
user@termix
|
||||||
</span>
|
</span>
|
||||||
<span>:</span>
|
<span>:</span>
|
||||||
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>~</span>
|
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>
|
||||||
|
~
|
||||||
|
</span>
|
||||||
<span>$ ls -la</span>
|
<span>$ ls -la</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -81,7 +93,9 @@ export function TerminalPreview({
|
|||||||
user@termix
|
user@termix
|
||||||
</span>
|
</span>
|
||||||
<span>:</span>
|
<span>:</span>
|
||||||
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>~</span>
|
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>
|
||||||
|
~
|
||||||
|
</span>
|
||||||
<span>$ </span>
|
<span>$ </span>
|
||||||
<span
|
<span
|
||||||
className="inline-block"
|
className="inline-block"
|
||||||
@@ -93,7 +107,8 @@ export function TerminalPreview({
|
|||||||
: cursorStyle === "bar"
|
: cursorStyle === "bar"
|
||||||
? `${fontSize}px`
|
? `${fontSize}px`
|
||||||
: `${fontSize}px`,
|
: `${fontSize}px`,
|
||||||
background: TERMINAL_THEMES[resolvedTheme]?.colors.cursor || "#f7f7f7",
|
background:
|
||||||
|
TERMINAL_THEMES[resolvedTheme]?.colors.cursor || "#f7f7f7",
|
||||||
animation: cursorBlink ? "blink 1s step-end infinite" : "none",
|
animation: cursorBlink ? "blink 1s step-end infinite" : "none",
|
||||||
verticalAlign:
|
verticalAlign:
|
||||||
cursorStyle === "underline" ? "bottom" : "text-bottom",
|
cursorStyle === "underline" ? "bottom" : "text-bottom",
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TunnelViewer } from "@/ui/desktop/apps/tunnel/TunnelViewer.tsx";
|
import { TunnelViewer } from "@/ui/desktop/apps/features/tunnel/TunnelViewer.tsx";
|
||||||
import {
|
import {
|
||||||
getSSHHosts,
|
getSSHHosts,
|
||||||
getTunnelStatuses,
|
getTunnelStatuses,
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||||
import { Separator } from "@/components/ui/separator.tsx";
|
import { Separator } from "@/components/ui/separator.tsx";
|
||||||
import { Tunnel } from "@/ui/desktop/apps/tunnel/Tunnel.tsx";
|
import { Tunnel } from "@/ui/desktop/apps/features/tunnel/Tunnel.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface HostConfig {
|
interface HostConfig {
|
||||||
@@ -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,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog.tsx";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Folder,
|
Folder,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -10,13 +10,18 @@ import {
|
|||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form.tsx";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
import { PasswordInput } from "@/components/ui/password-input";
|
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator.tsx";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import {
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "@/components/ui/tabs.tsx";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -28,18 +33,18 @@ import {
|
|||||||
detectPublicKeyType,
|
detectPublicKeyType,
|
||||||
generatePublicKeyFromPrivate,
|
generatePublicKeyFromPrivate,
|
||||||
generateKeyPair,
|
generateKeyPair,
|
||||||
} from "@/ui/main-axios";
|
} from "@/ui/main-axios.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import CodeMirror from "@uiw/react-codemirror";
|
import CodeMirror from "@uiw/react-codemirror";
|
||||||
import { oneDark } from "@codemirror/theme-one-dark";
|
import { oneDark } from "@codemirror/theme-one-dark";
|
||||||
import { githubLight } from "@uiw/codemirror-theme-github";
|
import { githubLight } from "@uiw/codemirror-theme-github";
|
||||||
import { EditorView } from "@codemirror/view";
|
import { EditorView } from "@codemirror/view";
|
||||||
import { useTheme } from "@/components/theme-provider";
|
import { useTheme } from "@/components/theme-provider.tsx";
|
||||||
import type {
|
import type {
|
||||||
Credential,
|
Credential,
|
||||||
CredentialEditorProps,
|
CredentialEditorProps,
|
||||||
CredentialData,
|
CredentialData,
|
||||||
} from "../../../../types/index.js";
|
} from "../../../../../types";
|
||||||
|
|
||||||
export function CredentialEditor({
|
export function CredentialEditor({
|
||||||
editingCredential,
|
editingCredential,
|
||||||
@@ -635,7 +640,7 @@ export function CredentialEditor({
|
|||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="flex-1 min-w-[60px] border-none outline-none bg-transparent p-0 h-6 text-sm"
|
className="flex-1 min-w-[60px] border-none outline-none bg-transparent text-foreground placeholder:text-muted-foreground p-0 h-6 text-sm"
|
||||||
value={tagInput}
|
value={tagInput}
|
||||||
onChange={(e) => setTagInput(e.target.value)}
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -950,7 +955,7 @@ export function CredentialEditor({
|
|||||||
"placeholders.pastePrivateKey",
|
"placeholders.pastePrivateKey",
|
||||||
)}
|
)}
|
||||||
theme={editorTheme}
|
theme={editorTheme}
|
||||||
className="border border-input rounded-md"
|
className="border border-input rounded-md overflow-hidden"
|
||||||
minHeight="120px"
|
minHeight="120px"
|
||||||
basicSetup={{
|
basicSetup={{
|
||||||
lineNumbers: true,
|
lineNumbers: true,
|
||||||
@@ -1113,7 +1118,7 @@ export function CredentialEditor({
|
|||||||
}}
|
}}
|
||||||
placeholder={t("placeholders.pastePublicKey")}
|
placeholder={t("placeholders.pastePublicKey")}
|
||||||
theme={editorTheme}
|
theme={editorTheme}
|
||||||
className="border border-input rounded-md"
|
className="border border-input rounded-md overflow-hidden"
|
||||||
minHeight="120px"
|
minHeight="120px"
|
||||||
basicSetup={{
|
basicSetup={{
|
||||||
lineNumbers: true,
|
lineNumbers: true,
|
||||||
@@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input.tsx";
|
|||||||
import { FormControl, FormItem, FormLabel } from "@/components/ui/form.tsx";
|
import { FormControl, FormItem, FormLabel } from "@/components/ui/form.tsx";
|
||||||
import { getCredentials } from "@/ui/main-axios.ts";
|
import { getCredentials } from "@/ui/main-axios.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Credential } from "../../../../types";
|
import type { Credential } from "../../../../../types";
|
||||||
|
|
||||||
interface CredentialSelectorProps {
|
interface CredentialSelectorProps {
|
||||||
value?: number | null;
|
value?: number | null;
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card.tsx";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge.tsx";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator.tsx";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetFooter,
|
SheetFooter,
|
||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet.tsx";
|
||||||
import {
|
import {
|
||||||
Key,
|
Key,
|
||||||
User,
|
User,
|
||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
FileText,
|
FileText,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getCredentialDetails, getCredentialHosts } from "@/ui/main-axios";
|
import { getCredentialDetails, getCredentialHosts } from "@/ui/main-axios.ts";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type {
|
import type {
|
||||||
@@ -1,33 +1,33 @@
|
|||||||
import React, { useState, useEffect, useMemo, useRef } from "react";
|
import React, { useState, useEffect, useMemo, useRef } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge.tsx";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
AccordionItem,
|
AccordionItem,
|
||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion.tsx";
|
||||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
import { Sheet, SheetContent } from "@/components/ui/sheet.tsx";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip.tsx";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover.tsx";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
CommandGroup,
|
CommandGroup,
|
||||||
CommandInput,
|
CommandInput,
|
||||||
CommandItem,
|
CommandItem,
|
||||||
} from "@/components/ui/command";
|
} from "@/components/ui/command.tsx";
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
Key,
|
Key,
|
||||||
@@ -46,7 +46,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils.ts";
|
||||||
import {
|
import {
|
||||||
getCredentials,
|
getCredentials,
|
||||||
deleteCredential,
|
deleteCredential,
|
||||||
@@ -54,15 +54,12 @@ import {
|
|||||||
renameCredentialFolder,
|
renameCredentialFolder,
|
||||||
deployCredentialToHost,
|
deployCredentialToHost,
|
||||||
getSSHHosts,
|
getSSHHosts,
|
||||||
} from "@/ui/main-axios";
|
} from "@/ui/main-axios.ts";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||||
import CredentialViewer from "./CredentialViewer";
|
import CredentialViewer from "./CredentialViewer.tsx";
|
||||||
import type {
|
import type { Credential, CredentialsManagerProps } from "../../../../../types";
|
||||||
Credential,
|
|
||||||
CredentialsManagerProps,
|
|
||||||
} from "../../../../types/index.js";
|
|
||||||
|
|
||||||
export function CredentialsManager({
|
export function CredentialsManager({
|
||||||
onEditCredential,
|
onEditCredential,
|
||||||
@@ -622,7 +619,8 @@ export function CredentialsManager({
|
|||||||
{credential.username}
|
{credential.username}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
{t("credentials.idLabel")} {credential.id}
|
{t("credentials.idLabel")}{" "}
|
||||||
|
{credential.id}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
{credential.authType === "password"
|
{credential.authType === "password"
|
||||||
@@ -867,7 +865,8 @@ export function CredentialsManager({
|
|||||||
{t("credentials.keyType")}
|
{t("credentials.keyType")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium">
|
<div className="text-sm font-medium">
|
||||||
{deployingCredential.keyType || t("credentials.sshKey")}
|
{deployingCredential.keyType ||
|
||||||
|
t("credentials.sshKey")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { HostManagerViewer } from "@/ui/desktop/apps/host-manager/HostManagerViewer.tsx";
|
import { HostManagerViewer } from "@/ui/desktop/apps/host-manager/hosts/HostManagerViewer.tsx";
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
} from "@/components/ui/tabs.tsx";
|
} from "@/components/ui/tabs.tsx";
|
||||||
import { Separator } from "@/components/ui/separator.tsx";
|
import { Separator } from "@/components/ui/separator.tsx";
|
||||||
import { HostManagerEditor } from "@/ui/desktop/apps/host-manager/HostManagerEditor.tsx";
|
import { HostManagerEditor } from "@/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx";
|
||||||
import { CredentialsManager } from "@/ui/desktop/apps/credentials/CredentialsManager.tsx";
|
import { CredentialsManager } from "@/ui/desktop/apps/host-manager/credentials/CredentialsManager.tsx";
|
||||||
import { CredentialEditor } from "@/ui/desktop/apps/credentials/CredentialEditor.tsx";
|
import { CredentialEditor } from "@/ui/desktop/apps/host-manager/credentials/CredentialEditor.tsx";
|
||||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { SSHHost, HostManagerProps } from "../../../types/index";
|
import type { SSHHost, HostManagerProps } from "../../../types/index";
|
||||||
@@ -117,10 +117,16 @@ export function HostManager({
|
|||||||
className="flex-1 flex flex-col h-full min-h-0"
|
className="flex-1 flex flex-col h-full min-h-0"
|
||||||
>
|
>
|
||||||
<TabsList className="bg-elevated border-2 border-edge mt-1.5">
|
<TabsList className="bg-elevated border-2 border-edge mt-1.5">
|
||||||
<TabsTrigger value="host_viewer" className="bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge">
|
<TabsTrigger
|
||||||
|
value="host_viewer"
|
||||||
|
className="bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
|
||||||
|
>
|
||||||
{t("hosts.hostViewer")}
|
{t("hosts.hostViewer")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="add_host" className="bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge">
|
<TabsTrigger
|
||||||
|
value="add_host"
|
||||||
|
className="bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
|
||||||
|
>
|
||||||
{editingHost
|
{editingHost
|
||||||
? editingHost.id
|
? editingHost.id
|
||||||
? t("hosts.editHost")
|
? t("hosts.editHost")
|
||||||
@@ -128,10 +134,16 @@ export function HostManager({
|
|||||||
: t("hosts.addHost")}
|
: t("hosts.addHost")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<div className="h-6 w-px bg-border-base mx-1"></div>
|
<div className="h-6 w-px bg-border-base mx-1"></div>
|
||||||
<TabsTrigger value="credentials" className="bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge">
|
<TabsTrigger
|
||||||
|
value="credentials"
|
||||||
|
className="bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
|
||||||
|
>
|
||||||
{t("credentials.credentialsViewer")}
|
{t("credentials.credentialsViewer")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="add_credential" className="bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge">
|
<TabsTrigger
|
||||||
|
value="add_credential"
|
||||||
|
className="bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
|
||||||
|
>
|
||||||
{editingCredential
|
{editingCredential
|
||||||
? t("credentials.editCredential")
|
? t("credentials.editCredential")
|
||||||
: t("credentials.addCredential")}
|
: t("credentials.addCredential")}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils.ts";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import {
|
import {
|
||||||
@@ -37,13 +37,15 @@ import {
|
|||||||
getSnippets,
|
getSnippets,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { CredentialSelector } from "@/ui/desktop/apps/credentials/CredentialSelector.tsx";
|
import { CredentialSelector } from "@/ui/desktop/apps/host-manager/credentials/CredentialSelector.tsx";
|
||||||
import { HostSharingTab } from "./HostSharingTab.tsx";
|
import { HostSharingTab } from "./HostSharingTab.tsx";
|
||||||
import CodeMirror from "@uiw/react-codemirror";
|
import CodeMirror from "@uiw/react-codemirror";
|
||||||
import { oneDark } from "@codemirror/theme-one-dark";
|
import { oneDark } from "@codemirror/theme-one-dark";
|
||||||
|
import { githubLight } from "@uiw/codemirror-theme-github";
|
||||||
import { EditorView } from "@codemirror/view";
|
import { EditorView } from "@codemirror/view";
|
||||||
import type { StatsConfig } from "@/types/stats-widgets";
|
import { useTheme } from "@/components/theme-provider.tsx";
|
||||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
import type { StatsConfig } from "@/types/stats-widgets.ts";
|
||||||
|
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets.ts";
|
||||||
import { Checkbox } from "@/components/ui/checkbox.tsx";
|
import { Checkbox } from "@/components/ui/checkbox.tsx";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -86,8 +88,8 @@ import {
|
|||||||
BELL_STYLES,
|
BELL_STYLES,
|
||||||
FAST_SCROLL_MODIFIERS,
|
FAST_SCROLL_MODIFIERS,
|
||||||
DEFAULT_TERMINAL_CONFIG,
|
DEFAULT_TERMINAL_CONFIG,
|
||||||
} from "@/constants/terminal-themes";
|
} from "@/constants/terminal-themes.ts";
|
||||||
import { TerminalPreview } from "@/ui/desktop/apps/terminal/TerminalPreview.tsx";
|
import { TerminalPreview } from "@/ui/desktop/apps/features/terminal/TerminalPreview.tsx";
|
||||||
import type { TerminalConfig, SSHHost, Credential } from "@/types";
|
import type { TerminalConfig, SSHHost, Credential } from "@/types";
|
||||||
import { Plus, X, Check, ChevronsUpDown, Save } from "lucide-react";
|
import { Plus, X, Check, ChevronsUpDown, Save } from "lucide-react";
|
||||||
|
|
||||||
@@ -300,6 +302,14 @@ export function HostManagerEditor({
|
|||||||
onFormSubmit,
|
onFormSubmit,
|
||||||
}: SSHManagerHostEditorProps) {
|
}: SSHManagerHostEditorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { theme: appTheme } = useTheme();
|
||||||
|
|
||||||
|
// Determine CodeMirror theme based on app theme
|
||||||
|
const isDarkMode =
|
||||||
|
appTheme === "dark" ||
|
||||||
|
(appTheme === "system" &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
const editorTheme = isDarkMode ? oneDark : githubLight;
|
||||||
const [folders, setFolders] = useState<string[]>([]);
|
const [folders, setFolders] = useState<string[]>([]);
|
||||||
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
|
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
|
||||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||||
@@ -1160,18 +1170,40 @@ export function HostManagerEditor({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<TabsList className="bg-button border border-edge-medium">
|
<TabsList className="bg-button border border-edge-medium">
|
||||||
<TabsTrigger value="general" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium">
|
<TabsTrigger
|
||||||
|
value="general"
|
||||||
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||||
|
>
|
||||||
{t("hosts.general")}
|
{t("hosts.general")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="terminal" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium">
|
<TabsTrigger
|
||||||
|
value="terminal"
|
||||||
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||||
|
>
|
||||||
{t("hosts.terminal")}
|
{t("hosts.terminal")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="docker" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium">Docker</TabsTrigger>
|
<TabsTrigger
|
||||||
<TabsTrigger value="tunnel" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium">{t("hosts.tunnel")}</TabsTrigger>
|
value="docker"
|
||||||
<TabsTrigger value="file_manager" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium">
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||||
|
>
|
||||||
|
Docker
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="tunnel"
|
||||||
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||||
|
>
|
||||||
|
{t("hosts.tunnel")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="file_manager"
|
||||||
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||||
|
>
|
||||||
{t("hosts.fileManager")}
|
{t("hosts.fileManager")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="statistics" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium">
|
<TabsTrigger
|
||||||
|
value="statistics"
|
||||||
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||||
|
>
|
||||||
{t("hosts.statistics")}
|
{t("hosts.statistics")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{!editingHost?.isShared && (
|
{!editingHost?.isShared && (
|
||||||
@@ -1359,7 +1391,7 @@ export function HostManagerEditor({
|
|||||||
))}
|
))}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="flex-1 min-w-[60px] border-none outline-none bg-transparent p-0 h-6"
|
className="flex-1 min-w-[60px] border-none outline-none bg-transparent text-foreground placeholder:text-muted-foreground p-0 h-6"
|
||||||
value={tagInput}
|
value={tagInput}
|
||||||
onChange={(e) => setTagInput(e.target.value)}
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -1444,14 +1476,30 @@ export function HostManagerEditor({
|
|||||||
className="flex-1 flex flex-col h-full min-h-0"
|
className="flex-1 flex flex-col h-full min-h-0"
|
||||||
>
|
>
|
||||||
<TabsList className="bg-button border border-edge-medium">
|
<TabsList className="bg-button border border-edge-medium">
|
||||||
<TabsTrigger value="password" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium">
|
<TabsTrigger
|
||||||
|
value="password"
|
||||||
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||||
|
>
|
||||||
{t("hosts.password")}
|
{t("hosts.password")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="key" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium">{t("hosts.key")}</TabsTrigger>
|
<TabsTrigger
|
||||||
<TabsTrigger value="credential" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium">
|
value="key"
|
||||||
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||||
|
>
|
||||||
|
{t("hosts.key")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="credential"
|
||||||
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||||
|
>
|
||||||
{t("hosts.credential")}
|
{t("hosts.credential")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="none" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium">{t("hosts.none")}</TabsTrigger>
|
<TabsTrigger
|
||||||
|
value="none"
|
||||||
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||||
|
>
|
||||||
|
{t("hosts.none")}
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="password">
|
<TabsContent value="password">
|
||||||
<FormField
|
<FormField
|
||||||
@@ -1559,8 +1607,8 @@ export function HostManagerEditor({
|
|||||||
placeholder={t(
|
placeholder={t(
|
||||||
"placeholders.pastePrivateKey",
|
"placeholders.pastePrivateKey",
|
||||||
)}
|
)}
|
||||||
theme={oneDark}
|
theme={editorTheme}
|
||||||
className="border border-input rounded-md"
|
className="border border-input rounded-md overflow-hidden"
|
||||||
minHeight="120px"
|
minHeight="120px"
|
||||||
basicSetup={{
|
basicSetup={{
|
||||||
lineNumbers: true,
|
lineNumbers: true,
|
||||||
@@ -1574,7 +1622,8 @@ export function HostManagerEditor({
|
|||||||
".cm-scroller": {
|
".cm-scroller": {
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
scrollbarWidth: "thin",
|
scrollbarWidth: "thin",
|
||||||
scrollbarColor: "var(--scrollbar-thumb) var(--scrollbar-track)",
|
scrollbarColor:
|
||||||
|
"var(--scrollbar-thumb) var(--scrollbar-track)",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]}
|
]}
|
||||||
@@ -1897,7 +1946,9 @@ export function HostManagerEditor({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("placeholders.socks5Host")}
|
placeholder={t(
|
||||||
|
"placeholders.socks5Host",
|
||||||
|
)}
|
||||||
{...field}
|
{...field}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
field.onChange(
|
field.onChange(
|
||||||
@@ -1925,7 +1976,9 @@ export function HostManagerEditor({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder={t("placeholders.socks5Port")}
|
placeholder={t(
|
||||||
|
"placeholders.socks5Port",
|
||||||
|
)}
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
field.onChange(
|
field.onChange(
|
||||||
@@ -1947,7 +2000,8 @@ export function HostManagerEditor({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("hosts.socks5Username")} {t("hosts.optional")}
|
{t("hosts.socks5Username")}{" "}
|
||||||
|
{t("hosts.optional")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
@@ -1971,7 +2025,8 @@ export function HostManagerEditor({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("hosts.socks5Password")} {t("hosts.optional")}
|
{t("hosts.socks5Password")}{" "}
|
||||||
|
{t("hosts.optional")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
@@ -2059,7 +2114,9 @@ export function HostManagerEditor({
|
|||||||
{t("hosts.socks5Host")}
|
{t("hosts.socks5Host")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("placeholders.socks5Host")}
|
placeholder={t(
|
||||||
|
"placeholders.socks5Host",
|
||||||
|
)}
|
||||||
value={node.host}
|
value={node.host}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const currentChain =
|
const currentChain =
|
||||||
@@ -2104,7 +2161,9 @@ export function HostManagerEditor({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder={t("placeholders.socks5Port")}
|
placeholder={t(
|
||||||
|
"placeholders.socks5Port",
|
||||||
|
)}
|
||||||
value={node.port}
|
value={node.port}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const currentChain =
|
const currentChain =
|
||||||
@@ -2167,7 +2226,8 @@ export function HostManagerEditor({
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("hosts.socks5Username")} {t("hosts.optional")}
|
{t("hosts.socks5Username")}{" "}
|
||||||
|
{t("hosts.optional")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("hosts.username")}
|
placeholder={t("hosts.username")}
|
||||||
@@ -2211,7 +2271,8 @@ export function HostManagerEditor({
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("hosts.socks5Password")} {t("hosts.optional")}
|
{t("hosts.socks5Password")}{" "}
|
||||||
|
{t("hosts.optional")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
placeholder={t("hosts.password")}
|
placeholder={t("hosts.password")}
|
||||||
@@ -2755,7 +2816,8 @@ export function HostManagerEditor({
|
|||||||
<FormLabel>{t("hosts.moshCommand")}</FormLabel>
|
<FormLabel>{t("hosts.moshCommand")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("placeholders.moshCommand")} {...field}
|
placeholder={t("placeholders.moshCommand")}
|
||||||
|
{...field}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
field.onChange(e.target.value.trim());
|
field.onChange(e.target.value.trim());
|
||||||
field.onBlur();
|
field.onBlur();
|
||||||
@@ -3071,7 +3133,9 @@ export function HostManagerEditor({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("placeholders.defaultPort")}
|
placeholder={t(
|
||||||
|
"placeholders.defaultPort",
|
||||||
|
)}
|
||||||
{...sourcePortField}
|
{...sourcePortField}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -3090,7 +3154,9 @@ export function HostManagerEditor({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("placeholders.defaultEndpointPort")}
|
placeholder={t(
|
||||||
|
"placeholders.defaultEndpointPort",
|
||||||
|
)}
|
||||||
{...endpointPortField}
|
{...endpointPortField}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -68,10 +68,10 @@ import type {
|
|||||||
SSHHost,
|
SSHHost,
|
||||||
SSHFolder,
|
SSHFolder,
|
||||||
SSHManagerHostViewerProps,
|
SSHManagerHostViewerProps,
|
||||||
} from "../../../../types/index.js";
|
} from "../../../../../types";
|
||||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets.ts";
|
||||||
import { FolderEditDialog } from "./components/FolderEditDialog";
|
import { FolderEditDialog } from "../components/FolderEditDialog.tsx";
|
||||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
|
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||||
|
|
||||||
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
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 React, { useEffect, useRef, useState, useMemo } from "react";
|
||||||
import { Terminal } from "@/ui/desktop/apps/terminal/Terminal.tsx";
|
import { Terminal } from "@/ui/desktop/apps/features/terminal/Terminal.tsx";
|
||||||
import { ServerStats as ServerView } from "@/ui/desktop/apps/server-stats/ServerStats.tsx";
|
import { ServerStats as ServerView } from "@/ui/desktop/apps/features/server-stats/ServerStats.tsx";
|
||||||
import { FileManager } from "@/ui/desktop/apps/file-manager/FileManager.tsx";
|
import { FileManager } from "@/ui/desktop/apps/features/file-manager/FileManager.tsx";
|
||||||
import { TunnelManager } from "@/ui/desktop/apps/tunnel/TunnelManager.tsx";
|
import { TunnelManager } from "@/ui/desktop/apps/features/tunnel/TunnelManager.tsx";
|
||||||
import { DockerManager } from "@/ui/desktop/apps/docker/DockerManager.tsx";
|
import { DockerManager } from "@/ui/desktop/apps/features/docker/DockerManager.tsx";
|
||||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||||
import {
|
import {
|
||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TabDropdown } from "@/ui/desktop/navigation/tabs/TabDropdown.tsx";
|
import { TabDropdown } from "@/ui/desktop/navigation/tabs/TabDropdown.tsx";
|
||||||
import { SSHToolsSidebar } from "@/ui/desktop/apps/tools/SSHToolsSidebar.tsx";
|
import { SSHToolsSidebar } from "@/ui/desktop/apps/tools/SSHToolsSidebar.tsx";
|
||||||
import { useCommandHistory } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx";
|
import { useCommandHistory } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
|
||||||
|
|
||||||
interface TabData {
|
interface TabData {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user