diff --git a/src/locales/en.json b/src/locales/en.json index e7290f95..3b450b6c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -975,7 +975,6 @@ "monitoringDisabledBadge": "Monitoring Off", "statusMonitoring": "Status", "metricsMonitoring": "Metrics", - "terminalCustomizationNotice": "Note: Terminal customizations only work on desktop (website and Electron app). Mobile apps and mobile website use system default terminal settings.", "terminalCustomization": "Terminal Customization", "appearance": "Appearance", "behavior": "Behavior", diff --git a/src/types/index.ts b/src/types/index.ts index 1c7b0d58..9c23e807 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -498,6 +498,8 @@ export interface HostManagerProps { _updateTimestamp?: number; rightSidebarOpen?: boolean; rightSidebarWidth?: number; + currentTabId?: number; + updateTab?: (tabId: number, updates: Partial>) => void; } export interface SSHManagerHostEditorProps { diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index d70e1e72..06669556 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -29,7 +29,7 @@ function AppContent() { const [transitionPhase, setTransitionPhase] = useState< "idle" | "fadeOut" | "fadeIn" >("idle"); - const { currentTab, tabs } = useTabs(); + const { currentTab, tabs, updateTab } = useTabs(); const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); const { theme, setTheme } = useTheme(); const [rightSidebarOpen, setRightSidebarOpen] = useState(false); @@ -280,6 +280,8 @@ function AppContent() { _updateTimestamp={currentTabData?._updateTimestamp} rightSidebarOpen={rightSidebarOpen} rightSidebarWidth={rightSidebarWidth} + currentTabId={currentTab} + updateTab={updateTab} /> )} diff --git a/src/ui/desktop/apps/host-manager/credentials/CredentialEditor.tsx b/src/ui/desktop/apps/host-manager/credentials/CredentialEditor.tsx index 795ffdec..806a0cda 100644 --- a/src/ui/desktop/apps/host-manager/credentials/CredentialEditor.tsx +++ b/src/ui/desktop/apps/host-manager/credentials/CredentialEditor.tsx @@ -1,18 +1,9 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { Controller, useForm } from "react-hook-form"; +import { useForm } from "react-hook-form"; import { z } from "zod"; import { Button } from "@/components/ui/button.tsx"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, -} from "@/components/ui/form.tsx"; -import { Input } from "@/components/ui/input.tsx"; -import { PasswordInput } from "@/components/ui/password-input.tsx"; +import { Form } from "@/components/ui/form.tsx"; import { ScrollArea } from "@/components/ui/scroll-area.tsx"; import { Separator } from "@/components/ui/separator.tsx"; import { @@ -31,20 +22,18 @@ import { getCredentialDetails, detectKeyType, detectPublicKeyType, - generatePublicKeyFromPrivate, - generateKeyPair, } from "@/ui/main-axios.ts"; import { useTranslation } from "react-i18next"; -import CodeMirror from "@uiw/react-codemirror"; import { oneDark } from "@codemirror/theme-one-dark"; import { githubLight } from "@uiw/codemirror-theme-github"; -import { EditorView } from "@codemirror/view"; import { useTheme } from "@/components/theme-provider.tsx"; import type { Credential, CredentialEditorProps, CredentialData, } from "../../../../../types"; +import { CredentialGeneralTab } from "./tabs/CredentialGeneralTab"; +import { CredentialAuthenticationTab } from "./tabs/CredentialAuthenticationTab"; export function CredentialEditor({ editingCredential, @@ -503,694 +492,39 @@ export function CredentialEditor({ - - {t("credentials.basicInformation")} - -
- ( - - {t("credentials.credentialName")} - - - - - )} - /> - - ( - - {t("credentials.username")} - - - - - )} - /> -
- - {t("credentials.organization")} - -
- ( - - {t("credentials.description")} - - - - - )} - /> - - ( - - {t("credentials.folder")} - - setFolderDropdownOpen(true)} - onChange={(e) => { - field.onChange(e); - setFolderDropdownOpen(true); - }} - /> - - {folderDropdownOpen && filteredFolders.length > 0 && ( -
-
- {filteredFolders.map((folder) => ( - - ))} -
-
- )} -
- )} - /> - - ( - - {t("credentials.tags")} - -
- {(field.value || []).map( - (tag: string, idx: number) => ( - - {tag} - - - ), - )} - setTagInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === " " && tagInput.trim() !== "") { - e.preventDefault(); - const currentTags = field.value || []; - if (!currentTags.includes(tagInput.trim())) { - field.onChange([ - ...currentTags, - tagInput.trim(), - ]); - } - setTagInput(""); - } else if ( - e.key === "Enter" && - tagInput.trim() !== "" - ) { - e.preventDefault(); - const currentTags = field.value || []; - if (!currentTags.includes(tagInput.trim())) { - field.onChange([ - ...currentTags, - tagInput.trim(), - ]); - } - setTagInput(""); - } else if ( - e.key === "Backspace" && - tagInput === "" && - (field.value || []).length > 0 - ) { - const currentTags = field.value || []; - field.onChange(currentTags.slice(0, -1)); - } - }} - placeholder={t("credentials.addTagsSpaceToAdd")} - /> -
-
-
- )} - /> -
+
- - {t("credentials.authentication")} - - { - const newAuthType = value as "password" | "key"; - setAuthTab(newAuthType); - form.setValue("authType", newAuthType); - - form.setValue("password", ""); - form.setValue("key", null); - form.setValue("keyPassword", ""); - form.setValue("keyType", "auto"); - }} - className="flex-1 flex flex-col h-full min-h-0" - > - - - {t("credentials.password")} - - - {t("credentials.key")} - - - - ( - - {t("credentials.password")} - - - - - )} - /> - - -
-
- - {t("credentials.generateKeyPair")} - - -
-
- {t("credentials.generateKeyPairDescription")} -
-
- -
- - - -
-
-
- ( - - - {t("credentials.sshPrivateKey")} - -
-
- { - const file = e.target.files?.[0]; - if (file) { - try { - const fileContent = await file.text(); - field.onChange(fileContent); - debouncedKeyDetection( - fileContent, - form.watch("keyPassword"), - ); - } catch (error) { - console.error( - "Failed to read uploaded file:", - error, - ); - } - } - }} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" - /> - -
-
- - { - field.onChange(value); - debouncedKeyDetection( - value, - form.watch("keyPassword"), - ); - }} - placeholder={t( - "placeholders.pastePrivateKey", - )} - theme={editorTheme} - className="border border-input rounded-md overflow-hidden" - minHeight="120px" - basicSetup={{ - lineNumbers: true, - foldGutter: false, - dropCursor: false, - allowMultipleSelections: false, - highlightSelectionMatches: false, - searchKeymap: false, - scrollPastEnd: false, - }} - extensions={[ - EditorView.theme({ - ".cm-scroller": { - overflow: "auto", - scrollbarWidth: "thin", - scrollbarColor: - "var(--scrollbar-thumb) var(--scrollbar-track)", - }, - }), - ]} - /> - - {detectedKeyType && ( -
- - {t("credentials.detectedKeyType")}:{" "} - - - {getFriendlyKeyTypeName(detectedKeyType)} - - {keyDetectionLoading && ( - - ({t("credentials.detectingKeyType")}) - - )} -
- )} -
- )} - /> - ( - - - {t("credentials.sshPublicKey")} - -
-
- { - const file = e.target.files?.[0]; - if (file) { - try { - const fileContent = await file.text(); - field.onChange(fileContent); - debouncedPublicKeyDetection( - fileContent, - ); - } catch (error) { - console.error( - "Failed to read uploaded public key file:", - error, - ); - } - } - }} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" - /> - -
- -
- - { - field.onChange(value); - debouncedPublicKeyDetection(value); - }} - placeholder={t("placeholders.pastePublicKey")} - theme={editorTheme} - className="border border-input rounded-md overflow-hidden" - minHeight="120px" - basicSetup={{ - lineNumbers: true, - foldGutter: false, - dropCursor: false, - allowMultipleSelections: false, - highlightSelectionMatches: false, - searchKeymap: false, - scrollPastEnd: false, - }} - extensions={[ - EditorView.theme({ - ".cm-scroller": { - overflow: "auto", - scrollbarWidth: "thin", - scrollbarColor: - "var(--scrollbar-thumb) var(--scrollbar-track)", - }, - }), - ]} - /> - - {detectedPublicKeyType && field.value && ( -
- - {t("credentials.detectedKeyType")}:{" "} - - - {getFriendlyKeyTypeName( - detectedPublicKeyType, - )} - - {publicKeyDetectionLoading && ( - - ({t("credentials.detectingKeyType")}) - - )} -
- )} -
- )} - /> -
-
- ( - - - {t("credentials.keyPassword")} - - - - - - )} - /> -
-
-
-
+
diff --git a/src/ui/desktop/apps/host-manager/credentials/tabs/CredentialAuthenticationTab.tsx b/src/ui/desktop/apps/host-manager/credentials/tabs/CredentialAuthenticationTab.tsx index d887fb01..7bb98fbd 100644 --- a/src/ui/desktop/apps/host-manager/credentials/tabs/CredentialAuthenticationTab.tsx +++ b/src/ui/desktop/apps/host-manager/credentials/tabs/CredentialAuthenticationTab.tsx @@ -1,73 +1,44 @@ -import React from "react"; -import { Controller } from "react-hook-form"; import { - FormControl, FormField, FormItem, FormLabel, + FormControl, } from "@/components/ui/form.tsx"; -import { Button } from "@/components/ui/button.tsx"; import { PasswordInput } from "@/components/ui/password-input.tsx"; +import { Button } from "@/components/ui/button.tsx"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "@/components/ui/tabs.tsx"; +import { Controller } from "react-hook-form"; import CodeMirror from "@uiw/react-codemirror"; import { EditorView } from "@codemirror/view"; +import React from "react"; +import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import type { Control, UseFormWatch, UseFormSetValue } from "react-hook-form"; - -interface CredentialAuthenticationTabProps { - control: Control; - watch: UseFormWatch; - setValue: UseFormSetValue; - authTab: "password" | "key"; - setAuthTab: (tab: "password" | "key") => void; - editorTheme: any; - detectedKeyType: string | null; - keyDetectionLoading: boolean; - detectedPublicKeyType: string | null; - publicKeyDetectionLoading: boolean; - debouncedKeyDetection: (keyValue: string, keyPassword?: string) => void; - debouncedPublicKeyDetection: (publicKeyValue: string) => void; - generateKeyPair: ( - type: string, - bits?: number, - passphrase?: string, - ) => Promise<{ - success: boolean; - privateKey: string; - publicKey: string; - error?: string; - }>; - generatePublicKeyFromPrivate: ( - privateKey: string, - passphrase?: string, - ) => Promise<{ success: boolean; publicKey?: string; error?: string }>; - getFriendlyKeyTypeName: (keyType: string) => string; - t: (key: string, params?: any) => string; -} - -export function CredentialAuthenticationTab({ - control, - watch, - setValue, - authTab, - setAuthTab, - editorTheme, - detectedKeyType, - keyDetectionLoading, - detectedPublicKeyType, - publicKeyDetectionLoading, - debouncedKeyDetection, - debouncedPublicKeyDetection, +import { generateKeyPair, generatePublicKeyFromPrivate, +} from "@/ui/main-axios.ts"; +import type { CredentialAuthenticationTabProps } from "./shared/tab-types"; + +export function CredentialAuthenticationTab({ + form, + authTab, + setAuthTab, + detectedKeyType, + detectedPublicKeyType, + keyDetectionLoading, + publicKeyDetectionLoading, + editorTheme, + debouncedKeyDetection, + debouncedPublicKeyDetection, getFriendlyKeyTypeName, - t, }: CredentialAuthenticationTabProps) { + const { t } = useTranslation(); + return ( <> @@ -78,12 +49,12 @@ export function CredentialAuthenticationTab({ onValueChange={(value) => { const newAuthType = value as "password" | "key"; setAuthTab(newAuthType); - setValue("authType", newAuthType); + form.setValue("authType", newAuthType); - setValue("password", ""); - setValue("key", null); - setValue("keyPassword", ""); - setValue("keyType", "auto"); + form.setValue("password", ""); + form.setValue("key", null); + form.setValue("keyPassword", ""); + form.setValue("keyType", "auto"); }} className="flex-1 flex flex-col h-full min-h-0" > @@ -103,7 +74,7 @@ export function CredentialAuthenticationTab({ ( @@ -138,7 +109,7 @@ export function CredentialAuthenticationTab({ size="sm" onClick={async () => { try { - const currentKeyPassword = watch("keyPassword"); + const currentKeyPassword = form.watch("keyPassword"); const result = await generateKeyPair( "ssh-ed25519", undefined, @@ -146,8 +117,8 @@ export function CredentialAuthenticationTab({ ); if (result.success) { - setValue("key", result.privateKey); - setValue("publicKey", result.publicKey); + form.setValue("key", result.privateKey); + form.setValue("publicKey", result.publicKey); debouncedKeyDetection( result.privateKey, currentKeyPassword, @@ -181,7 +152,7 @@ export function CredentialAuthenticationTab({ size="sm" onClick={async () => { try { - const currentKeyPassword = watch("keyPassword"); + const currentKeyPassword = form.watch("keyPassword"); const result = await generateKeyPair( "ecdsa-sha2-nistp256", undefined, @@ -189,8 +160,8 @@ export function CredentialAuthenticationTab({ ); if (result.success) { - setValue("key", result.privateKey); - setValue("publicKey", result.publicKey); + form.setValue("key", result.privateKey); + form.setValue("publicKey", result.publicKey); debouncedKeyDetection( result.privateKey, currentKeyPassword, @@ -224,7 +195,7 @@ export function CredentialAuthenticationTab({ size="sm" onClick={async () => { try { - const currentKeyPassword = watch("keyPassword"); + const currentKeyPassword = form.watch("keyPassword"); const result = await generateKeyPair( "ssh-rsa", 2048, @@ -232,8 +203,8 @@ export function CredentialAuthenticationTab({ ); if (result.success) { - setValue("key", result.privateKey); - setValue("publicKey", result.publicKey); + form.setValue("key", result.privateKey); + form.setValue("publicKey", result.publicKey); debouncedKeyDetection( result.privateKey, currentKeyPassword, @@ -262,7 +233,7 @@ export function CredentialAuthenticationTab({
( @@ -283,7 +254,7 @@ export function CredentialAuthenticationTab({ field.onChange(fileContent); debouncedKeyDetection( fileContent, - watch("keyPassword"), + form.watch("keyPassword"), ); } catch (error) { console.error( @@ -313,7 +284,10 @@ export function CredentialAuthenticationTab({ } onChange={(value) => { field.onChange(value); - debouncedKeyDetection(value, watch("keyPassword")); + debouncedKeyDetection( + value, + form.watch("keyPassword"), + ); }} placeholder={t("placeholders.pastePrivateKey")} theme={editorTheme} @@ -366,7 +340,7 @@ export function CredentialAuthenticationTab({ )} /> ( @@ -411,7 +385,7 @@ export function CredentialAuthenticationTab({ variant="outline" className="flex-shrink-0" onClick={async () => { - const privateKey = watch("key"); + const privateKey = form.watch("key"); if ( !privateKey || typeof privateKey !== "string" || @@ -424,7 +398,7 @@ export function CredentialAuthenticationTab({ } try { - const keyPassword = watch("keyPassword"); + const keyPassword = form.watch("keyPassword"); const result = await generatePublicKeyFromPrivate( privateKey, keyPassword, @@ -517,7 +491,7 @@ export function CredentialAuthenticationTab({
( diff --git a/src/ui/desktop/apps/host-manager/credentials/tabs/CredentialGeneralTab.tsx b/src/ui/desktop/apps/host-manager/credentials/tabs/CredentialGeneralTab.tsx index fedadcd9..1a75a48c 100644 --- a/src/ui/desktop/apps/host-manager/credentials/tabs/CredentialGeneralTab.tsx +++ b/src/ui/desktop/apps/host-manager/credentials/tabs/CredentialGeneralTab.tsx @@ -1,70 +1,28 @@ -import React, { useRef, useState, useEffect } from "react"; import { - FormControl, FormField, FormItem, FormLabel, + FormControl, } from "@/components/ui/form.tsx"; import { Input } from "@/components/ui/input.tsx"; import { Button } from "@/components/ui/button.tsx"; -import type { Control, UseFormWatch } from "react-hook-form"; - -interface CredentialGeneralTabProps { - control: Control; - watch: UseFormWatch; - folders: string[]; - t: (key: string, params?: any) => string; -} +import React from "react"; +import { useTranslation } from "react-i18next"; +import type { CredentialGeneralTabProps } from "./shared/tab-types"; export function CredentialGeneralTab({ - control, - watch, + form, folders, - t, + tagInput, + setTagInput, + folderDropdownOpen, + setFolderDropdownOpen, + folderInputRef, + folderDropdownRef, + filteredFolders, + handleFolderClick, }: CredentialGeneralTabProps) { - const [tagInput, setTagInput] = useState(""); - const [folderDropdownOpen, setFolderDropdownOpen] = useState(false); - const folderInputRef = useRef(null); - const folderDropdownRef = useRef(null); - - const folderValue = watch("folder"); - const filteredFolders = React.useMemo(() => { - if (!folderValue) return folders; - return folders.filter((f) => - f.toLowerCase().includes(folderValue.toLowerCase()), - ); - }, [folderValue, folders]); - - const handleFolderClick = ( - folder: string, - onChange: (value: string) => void, - ) => { - onChange(folder); - setFolderDropdownOpen(false); - }; - - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if ( - folderDropdownRef.current && - !folderDropdownRef.current.contains(event.target as Node) && - folderInputRef.current && - !folderInputRef.current.contains(event.target as Node) - ) { - setFolderDropdownOpen(false); - } - } - - if (folderDropdownOpen) { - document.addEventListener("mousedown", handleClickOutside); - } else { - document.removeEventListener("mousedown", handleClickOutside); - } - - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [folderDropdownOpen]); + const { t } = useTranslation(); return ( <> @@ -73,7 +31,7 @@ export function CredentialGeneralTab({
( @@ -89,7 +47,7 @@ export function CredentialGeneralTab({ /> ( @@ -106,7 +64,7 @@ export function CredentialGeneralTab({
( @@ -119,7 +77,7 @@ export function CredentialGeneralTab({ /> ( @@ -151,9 +109,7 @@ export function CredentialGeneralTab({ variant="ghost" size="sm" className="w-full justify-start text-left rounded px-2 py-1.5 hover:bg-white/15 focus:bg-white/20 focus:outline-none" - onClick={() => - handleFolderClick(folder, field.onChange) - } + onClick={() => handleFolderClick(folder)} > {folder} @@ -166,7 +122,7 @@ export function CredentialGeneralTab({ /> ( diff --git a/src/ui/desktop/apps/host-manager/credentials/tabs/shared/tab-types.ts b/src/ui/desktop/apps/host-manager/credentials/tabs/shared/tab-types.ts new file mode 100644 index 00000000..2fb22ef6 --- /dev/null +++ b/src/ui/desktop/apps/host-manager/credentials/tabs/shared/tab-types.ts @@ -0,0 +1,35 @@ +import type { UseFormReturn } from "react-hook-form"; +import type React from "react"; + +export interface CredentialGeneralTabProps { + form: UseFormReturn; + folders: string[]; + tagInput: string; + setTagInput: (value: string) => void; + folderDropdownOpen: boolean; + setFolderDropdownOpen: (value: boolean) => void; + folderInputRef: React.RefObject; + folderDropdownRef: React.RefObject; + filteredFolders: string[]; + handleFolderClick: (folder: string) => void; +} + +export interface CredentialAuthenticationTabProps { + form: UseFormReturn; + authTab: "password" | "key"; + setAuthTab: (value: "password" | "key") => void; + detectedKeyType: string | null; + setDetectedKeyType: (value: string | null) => void; + keyDetectionLoading: boolean; + setKeyDetectionLoading: (value: boolean) => void; + detectedPublicKeyType: string | null; + setDetectedPublicKeyType: (value: string | null) => void; + publicKeyDetectionLoading: boolean; + setPublicKeyDetectionLoading: (value: boolean) => void; + keyDetectionTimeoutRef: React.MutableRefObject; + publicKeyDetectionTimeoutRef: React.MutableRefObject; + editorTheme: unknown; + debouncedKeyDetection: (keyValue: string, keyPassword?: string) => void; + debouncedPublicKeyDetection: (publicKeyValue: string) => void; + getFriendlyKeyTypeName: (keyType: string) => string; +} diff --git a/src/ui/desktop/apps/host-manager/hosts/HostManager.tsx b/src/ui/desktop/apps/host-manager/hosts/HostManager.tsx index e06be97f..3b13ff39 100644 --- a/src/ui/desktop/apps/host-manager/hosts/HostManager.tsx +++ b/src/ui/desktop/apps/host-manager/hosts/HostManager.tsx @@ -21,6 +21,8 @@ export function HostManager({ _updateTimestamp, rightSidebarOpen = false, rightSidebarWidth = 400, + currentTabId, + updateTab, }: HostManagerProps): React.ReactElement { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState(initialTab); @@ -75,6 +77,11 @@ export function HostManager({ setEditingHost(host); setActiveTab("add_host"); lastProcessedHostIdRef.current = host.id; + + // Persist to tab context + if (updateTab && currentTabId !== undefined) { + updateTab(currentTabId, { initialTab: "add_host" }); + } }; const handleFormSubmit = () => { @@ -93,6 +100,11 @@ export function HostManager({ }) => { setEditingCredential(credential); setActiveTab("add_credential"); + + // Persist to tab context + if (updateTab && currentTabId !== undefined) { + updateTab(currentTabId, { initialTab: "add_credential" }); + } }; const handleCredentialFormSubmit = () => { @@ -108,6 +120,11 @@ export function HostManager({ setEditingCredential(null); } setActiveTab(value); + + // Persist to tab context + if (updateTab && currentTabId !== undefined) { + updateTab(currentTabId, { initialTab: value }); + } }; const topMarginPx = isTopbarOpen ? 74 : 26; diff --git a/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx index 1582550e..5487d458 100644 --- a/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx @@ -14,6 +14,15 @@ import { } from "@/components/ui/form.tsx"; import { Input } from "@/components/ui/input.tsx"; import { PasswordInput } from "@/components/ui/password-input.tsx"; +import { Badge } from "@/components/ui/badge.tsx"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table.tsx"; import { Textarea } from "@/components/ui/textarea.tsx"; import { ScrollArea } from "@/components/ui/scroll-area.tsx"; import { Separator } from "@/components/ui/separator.tsx"; @@ -25,8 +34,9 @@ import { } from "@/components/ui/tabs.tsx"; import React, { useEffect, useRef, useState } from "react"; import { Switch } from "@/components/ui/switch.tsx"; -import { Alert, AlertDescription } from "@/components/ui/alert.tsx"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx"; import { toast } from "sonner"; +import { useConfirmation } from "@/hooks/use-confirmation.ts"; import { createSSHHost, getCredentials, @@ -35,10 +45,18 @@ import { enableAutoStart, disableAutoStart, getSnippets, + getRoles, + getUserList, + getUserInfo, + shareHost, + getHostAccess, + revokeHostAccess, + getSSHHostById, + type Role, + type AccessRecord, } from "@/ui/main-axios.ts"; import { useTranslation } from "react-i18next"; import { CredentialSelector } from "@/ui/desktop/apps/host-manager/credentials/CredentialSelector.tsx"; -import { HostSharingTab } from "./tabs/HostSharingTab.tsx"; import CodeMirror from "@uiw/react-codemirror"; import { oneDark } from "@codemirror/theme-one-dark"; import { githubLight } from "@uiw/codemirror-theme-github"; @@ -91,7 +109,25 @@ import { } from "@/constants/terminal-themes.ts"; import { TerminalPreview } from "@/ui/desktop/apps/features/terminal/TerminalPreview.tsx"; import type { TerminalConfig, SSHHost, Credential } from "@/types"; -import { Plus, X, Check, ChevronsUpDown, Save } from "lucide-react"; +import { + Plus, + X, + Check, + ChevronsUpDown, + Save, + AlertCircle, + Trash2, + Users, + Shield, + Clock, + UserCircle, +} from "lucide-react"; + +interface User { + id: string; + username: string; + is_admin: boolean; +} interface JumpHostItemProps { jumpHost: { hostId: number }; @@ -292,6 +328,503 @@ function QuickActionItem({ ); } +const PERMISSION_LEVELS = [{ value: "view", labelKey: "rbac.view" }]; + +interface SharingTabContentProps { + hostId: number | undefined; + isNewHost: boolean; +} + +function SharingTabContent({ + hostId, + isNewHost, +}: SharingTabContentProps): React.ReactElement { + const { t } = useTranslation(); + const { confirmWithToast } = useConfirmation(); + + const [shareType, setShareType] = React.useState<"user" | "role">("user"); + const [selectedUserId, setSelectedUserId] = React.useState(""); + const [selectedRoleId, setSelectedRoleId] = React.useState( + null, + ); + const [permissionLevel, setPermissionLevel] = React.useState("view"); + const [expiresInHours, setExpiresInHours] = React.useState(""); + + const [roles, setRoles] = React.useState([]); + const [users, setUsers] = React.useState([]); + const [accessList, setAccessList] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const [currentUserId, setCurrentUserId] = React.useState(""); + const [hostData, setHostData] = React.useState(null); + + const [userComboOpen, setUserComboOpen] = React.useState(false); + const [roleComboOpen, setRoleComboOpen] = React.useState(false); + + const loadRoles = React.useCallback(async () => { + try { + const response = await getRoles(); + setRoles(response.roles || []); + } catch (error) { + console.error("Failed to load roles:", error); + setRoles([]); + } + }, []); + + const loadUsers = React.useCallback(async () => { + try { + const response = await getUserList(); + 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([]); + } + }, []); + + 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]); + + 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]); + + React.useEffect(() => { + const fetchCurrentUser = async () => { + try { + const userInfo = await getUserInfo(); + setCurrentUserId(userInfo.userId); + } catch (error) { + console.error("Failed to load current user:", error); + } + }; + fetchCurrentUser(); + }, []); + + 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; + } + + 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")); + } + }; + + 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")); + } + }; + + const formatDate = (dateString: string | null) => { + if (!dateString) return "-"; + return new Date(dateString).toLocaleString(); + }; + + const isExpired = (expiresAt: string | null) => { + if (!expiresAt) return false; + return new Date(expiresAt) < new Date(); + }; + + 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 ( + + + {t("rbac.saveHostFirst")} + + {t("rbac.saveHostFirstDescription")} + + + ); + } + + return ( +
+ {!hostData?.credentialId && ( + + + {t("rbac.credentialRequired")} + + {t("rbac.credentialRequiredDescription")} + + + )} + + {hostData?.credentialId && ( + <> +
+

+ + {t("rbac.shareHost")} +

+ + setShareType(v as "user" | "role")} + > + + + + {t("rbac.shareWithUser")} + + + + {t("rbac.shareWithRole")} + + + + +
+ + + + + + + + + {t("rbac.noUserFound")} + + {availableUsers.map((user) => ( + { + setSelectedUserId(user.id); + setUserComboOpen(false); + }} + > + + {user.username} + {user.is_admin ? " (Admin)" : ""} + + ))} + + + + +
+
+ + +
+ + + + + + + + + {t("rbac.noRoleFound")} + + {roles.map((role) => ( + { + setSelectedRoleId(role.id); + setRoleComboOpen(false); + }} + > + + {t(role.displayName)} + {role.isSystem + ? ` (${t("rbac.systemRole")})` + : ""} + + ))} + + + + +
+
+
+ +
+ +
+ {t("rbac.view")} - {t("rbac.viewDesc")} +
+
+ +
+ + { + const value = e.target.value; + if (value === "" || /^\d+$/.test(value)) { + setExpiresInHours(value); + } + }} + placeholder={t("rbac.neverExpires")} + min="1" + /> +
+ + +
+ +
+

+ + {t("rbac.accessList")} +

+ + + + + {t("rbac.type")} + {t("rbac.target")} + {t("rbac.permissionLevel")} + {t("rbac.grantedBy")} + {t("rbac.expires")} + + {t("common.actions")} + + + + + {loading ? ( + + + {t("common.loading")} + + + ) : accessList.length === 0 ? ( + + + {t("rbac.noAccessRecords")} + + + ) : ( + accessList.map((access) => ( + + + {access.targetType === "user" ? ( + + + {t("rbac.user")} + + ) : ( + + + {t("rbac.role")} + + )} + + + {access.targetType === "user" + ? access.username + : t(access.roleDisplayName || access.roleName || "")} + + + + {access.permissionLevel} + + + {access.grantedByUsername} + + {access.expiresAt ? ( +
+ + + {formatDate(access.expiresAt)} + {isExpired(access.expiresAt) && ( + + ({t("rbac.expired")}) + + )} + +
+ ) : ( + t("rbac.never") + )} +
+ + + +
+ )) + )} +
+
+
+ + )} +
+ ); +} + interface SSHManagerHostEditorProps { editingHost?: SSHHost | null; onFormSubmit?: (updatedHost?: SSHHost) => void; @@ -2322,7 +2855,28 @@ export function HostManagerEditor({ - + + ( + + {t("hosts.enableTerminal")} + + + + + {t("hosts.enableTerminalDesc")} + + + )} + /> +

+ {t("hosts.terminalCustomization")} +

+
+ + +
+ + ( + + {t("hosts.theme")} + + + {t("hosts.chooseColorTheme")} + + + )} + /> + + ( + + {t("hosts.fontFamily")} + + + {t("hosts.selectFontDesc")} + + + )} + /> + + ( + + + {t("hosts.fontSizeValue", { + value: field.value, + })} + + + + field.onChange(value) + } + /> + + + {t("hosts.adjustFontSize")} + + + )} + /> + - diff --git a/src/ui/desktop/apps/host-manager/hosts/tabs/HostAuthenticationSection.tsx b/src/ui/desktop/apps/host-manager/hosts/tabs/HostAuthenticationSection.tsx deleted file mode 100644 index f562d673..00000000 --- a/src/ui/desktop/apps/host-manager/hosts/tabs/HostAuthenticationSection.tsx +++ /dev/null @@ -1,349 +0,0 @@ -import React, { useRef, useState, useEffect } from "react"; -import { Controller } from "react-hook-form"; -import { - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, -} from "@/components/ui/form.tsx"; -import { Button } from "@/components/ui/button.tsx"; -import { Switch } from "@/components/ui/switch.tsx"; -import { PasswordInput } from "@/components/ui/password-input.tsx"; -import { Alert, AlertDescription } from "@/components/ui/alert.tsx"; -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "@/components/ui/tabs.tsx"; -import CodeMirror from "@uiw/react-codemirror"; -import { EditorView } from "@codemirror/view"; -import { CredentialSelector } from "@/ui/desktop/apps/host-manager/credentials/CredentialSelector.tsx"; -import type { HostAuthenticationSectionProps } from "./shared/tab-types"; - -export function HostAuthenticationSection({ - control, - watch, - setValue, - credentials, - authTab, - setAuthTab, - keyInputMethod, - setKeyInputMethod, - editorTheme, - editingHost, - t, -}: HostAuthenticationSectionProps) { - const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false); - const keyTypeButtonRef = useRef(null); - const keyTypeDropdownRef = useRef(null); - - const keyTypeOptions = [ - { value: "auto", label: t("hosts.autoDetect") }, - { value: "ssh-rsa", label: t("hosts.rsa") }, - { value: "ssh-ed25519", label: t("hosts.ed25519") }, - { value: "ecdsa-sha2-nistp256", label: t("hosts.ecdsaNistP256") }, - { value: "ecdsa-sha2-nistp384", label: t("hosts.ecdsaNistP384") }, - { value: "ecdsa-sha2-nistp521", label: t("hosts.ecdsaNistP521") }, - { value: "ssh-dss", label: t("hosts.dsa") }, - { value: "ssh-rsa-sha2-256", label: t("hosts.rsaSha2256") }, - { value: "ssh-rsa-sha2-512", label: t("hosts.rsaSha2512") }, - ]; - - useEffect(() => { - function onClickOutside(event: MouseEvent) { - if ( - keyTypeDropdownOpen && - keyTypeDropdownRef.current && - !keyTypeDropdownRef.current.contains(event.target as Node) && - keyTypeButtonRef.current && - !keyTypeButtonRef.current.contains(event.target as Node) - ) { - setKeyTypeDropdownOpen(false); - } - } - - document.addEventListener("mousedown", onClickOutside); - return () => document.removeEventListener("mousedown", onClickOutside); - }, [keyTypeDropdownOpen]); - - return ( - { - const newAuthType = value as "password" | "key" | "credential" | "none"; - setAuthTab(newAuthType); - setValue("authType", newAuthType); - }} - className="flex-1 flex flex-col h-full min-h-0" - > - - - {t("hosts.password")} - - - {t("hosts.key")} - - - {t("hosts.credential")} - - - {t("hosts.none")} - - - - ( - - {t("hosts.password")} - - - - - )} - /> - - - { - setKeyInputMethod(value as "upload" | "paste"); - if (value === "upload") { - setValue("key", null); - } else { - setValue("key", ""); - } - }} - className="w-full" - > - - {t("hosts.uploadFile")} - {t("hosts.pasteKey")} - - - ( - - {t("hosts.sshPrivateKey")} - -
- { - const file = e.target.files?.[0]; - field.onChange(file || null); - }} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" - /> - -
-
-
- )} - /> -
- - ( - - {t("hosts.sshPrivateKey")} - - field.onChange(value)} - placeholder={t("placeholders.pastePrivateKey")} - theme={editorTheme} - className="border border-input rounded-md overflow-hidden" - minHeight="120px" - basicSetup={{ - lineNumbers: true, - foldGutter: false, - dropCursor: false, - allowMultipleSelections: false, - highlightSelectionMatches: false, - }} - extensions={[ - EditorView.theme({ - ".cm-scroller": { - overflow: "auto", - scrollbarWidth: "thin", - scrollbarColor: - "var(--scrollbar-thumb) var(--scrollbar-track)", - }, - }), - ]} - /> - - - )} - /> - -
-
- ( - - {t("hosts.keyPassword")} - - - - - )} - /> - ( - - {t("hosts.keyType")} - -
- - {keyTypeDropdownOpen && ( -
-
- {keyTypeOptions.map((opt) => ( - - ))} -
-
- )} -
-
-
- )} - /> -
-
- -
- ( - - { - if (credential && !watch("overrideCredentialUsername")) { - setValue("username", credential.username); - } - }} - /> - - {t("hosts.credentialDescription")} - - - )} - /> - {watch("credentialId") && ( - ( - -
- - {t("hosts.overrideCredentialUsername")} - - - {t("hosts.overrideCredentialUsernameDesc")} - -
- - - -
- )} - /> - )} -
-
- - - - {t("hosts.noneAuthTitle")} -
{t("hosts.noneAuthDescription")}
-
{t("hosts.noneAuthDetails")}
-
-
-
-
- ); -} diff --git a/src/ui/desktop/apps/host-manager/hosts/tabs/HostDockerTab.tsx b/src/ui/desktop/apps/host-manager/hosts/tabs/HostDockerTab.tsx deleted file mode 100644 index ba5d4070..00000000 --- a/src/ui/desktop/apps/host-manager/hosts/tabs/HostDockerTab.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, -} from "@/components/ui/form.tsx"; -import { Switch } from "@/components/ui/switch.tsx"; -import type { HostDockerTabProps } from "./shared/tab-types"; -import { Button } from "@/components/ui/button.tsx"; -import React from "react"; - -export function HostDockerTab({ control, t }: HostDockerTabProps) { - return ( -
- - ( - - {t("hosts.enableDocker")} - - - - {t("hosts.enableDockerDesc")} - - )} - /> -
- ); -} diff --git a/src/ui/desktop/apps/host-manager/hosts/tabs/HostFileManagerTab.tsx b/src/ui/desktop/apps/host-manager/hosts/tabs/HostFileManagerTab.tsx deleted file mode 100644 index 34f2ee06..00000000 --- a/src/ui/desktop/apps/host-manager/hosts/tabs/HostFileManagerTab.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, -} from "@/components/ui/form.tsx"; -import { Switch } from "@/components/ui/switch.tsx"; -import { Input } from "@/components/ui/input.tsx"; -import type { HostFileManagerTabProps } from "./shared/tab-types"; - -export function HostFileManagerTab({ - control, - watch, - t, -}: HostFileManagerTabProps) { - return ( -
- ( - - {t("hosts.enableFileManager")} - - - - - {t("hosts.enableFileManagerDesc")} - - - )} - /> - - {watch("enableFileManager") && ( -
- ( - - {t("hosts.defaultPath")} - - { - field.onChange(e.target.value.trim()); - field.onBlur(); - }} - /> - - {t("hosts.defaultPathDesc")} - - )} - /> -
- )} -
- ); -} diff --git a/src/ui/desktop/apps/host-manager/hosts/tabs/HostGeneralTab.tsx b/src/ui/desktop/apps/host-manager/hosts/tabs/HostGeneralTab.tsx deleted file mode 100644 index b37fd5fe..00000000 --- a/src/ui/desktop/apps/host-manager/hosts/tabs/HostGeneralTab.tsx +++ /dev/null @@ -1,785 +0,0 @@ -import React, { useRef, useState, useEffect } from "react"; -import { - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, -} from "@/components/ui/form.tsx"; -import { Input } from "@/components/ui/input.tsx"; -import { Button } from "@/components/ui/button.tsx"; -import { Switch } from "@/components/ui/switch.tsx"; -import { Textarea } from "@/components/ui/textarea.tsx"; -import { Separator } from "@/components/ui/separator.tsx"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion.tsx"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select.tsx"; -import { Alert, AlertDescription } from "@/components/ui/alert.tsx"; -import { Plus, X } from "lucide-react"; -import { PasswordInput } from "@/components/ui/password-input.tsx"; -import { JumpHostItem } from "./shared/JumpHostItem.tsx"; -import { HostAuthenticationSection } from "./HostAuthenticationSection.tsx"; -import type { HostGeneralTabProps } from "./shared/tab-types"; - -export function HostGeneralTab({ - control, - watch, - setValue, - getValues, - hosts, - credentials, - folders, - snippets, - editorTheme, - editingHost, - authTab, - setAuthTab, - keyInputMethod, - setKeyInputMethod, - proxyMode, - setProxyMode, - ipInputRef, - t, -}: HostGeneralTabProps) { - const [tagInput, setTagInput] = useState(""); - const [folderDropdownOpen, setFolderDropdownOpen] = useState(false); - const folderInputRef = useRef(null); - const folderDropdownRef = useRef(null); - - const folderValue = watch("folder"); - const filteredFolders = React.useMemo(() => { - if (!folderValue) return folders; - return folders.filter((f) => - f.toLowerCase().includes(folderValue.toLowerCase()), - ); - }, [folderValue, folders]); - - const handleFolderClick = (folder: string) => { - setValue("folder", folder); - setFolderDropdownOpen(false); - }; - - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if ( - folderDropdownRef.current && - !folderDropdownRef.current.contains(event.target as Node) && - folderInputRef.current && - !folderInputRef.current.contains(event.target as Node) - ) { - setFolderDropdownOpen(false); - } - } - - if (folderDropdownOpen) { - document.addEventListener("mousedown", handleClickOutside); - } else { - document.removeEventListener("mousedown", handleClickOutside); - } - - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [folderDropdownOpen]); - - return ( - <> - - {t("hosts.connectionDetails")} - -
- ( - - {t("hosts.ipAddress")} - - { - field.ref(e); - if (ipInputRef?.current) { - ipInputRef.current = e; - } - }} - onBlur={(e) => { - field.onChange(e.target.value.trim()); - field.onBlur(); - }} - /> - - - )} - /> - - ( - - {t("hosts.port")} - - - - - )} - /> - - { - const isCredentialAuth = authTab === "credential"; - const hasCredential = !!watch("credentialId"); - const overrideEnabled = !!watch("overrideCredentialUsername"); - const shouldDisable = - isCredentialAuth && hasCredential && !overrideEnabled; - - return ( - - {t("hosts.username")} - - { - field.onChange(e.target.value.trim()); - field.onBlur(); - }} - /> - - - ); - }} - /> -
- - {t("hosts.organization")} - -
- ( - - {t("hosts.name")} - - { - field.onChange(e.target.value.trim()); - field.onBlur(); - }} - /> - - - )} - /> - - ( - - {t("hosts.folder")} - - setFolderDropdownOpen(true)} - onChange={(e) => { - field.onChange(e); - setFolderDropdownOpen(true); - }} - onBlur={(e) => { - field.onChange(e.target.value.trim()); - field.onBlur(); - }} - /> - - {folderDropdownOpen && filteredFolders.length > 0 && ( -
-
- {filteredFolders.map((folder) => ( - - ))} -
-
- )} -
- )} - /> - - ( - - {t("hosts.tags")} - -
- {field.value.map((tag: string, idx: number) => ( - - {tag} - - - ))} - setTagInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === " " && tagInput.trim() !== "") { - e.preventDefault(); - if (!field.value.includes(tagInput.trim())) { - field.onChange([...field.value, tagInput.trim()]); - } - setTagInput(""); - } else if ( - e.key === "Backspace" && - tagInput === "" && - field.value.length > 0 - ) { - field.onChange(field.value.slice(0, -1)); - } - }} - placeholder={t("hosts.addTagsSpaceToAdd")} - /> -
-
-
- )} - /> - - ( - - {t("hosts.pin")} - - - - - )} - /> - - ( - - {t("hosts.notes")} - -