v1.10.0 #471

Merged
LukeGus merged 106 commits from dev-1.10.0 into main 2026-01-01 04:20:12 +00:00
18 changed files with 837 additions and 4307 deletions
Showing only changes of commit 7c850c1072 - Show all commits

View File

@@ -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",

View File

@@ -498,6 +498,8 @@ export interface HostManagerProps {
_updateTimestamp?: number;
rightSidebarOpen?: boolean;
rightSidebarWidth?: number;
currentTabId?: number;
updateTab?: (tabId: number, updates: Partial<Omit<Tab, "id">>) => void;
}
export interface SSHManagerHostEditorProps {

View File

@@ -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}
/>
</div>
)}

View File

@@ -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({
</TabsTrigger>
</TabsList>
<TabsContent value="general" className="pt-2">
<FormLabel className="mb-2 font-bold">
{t("credentials.basicInformation")}
</FormLabel>
<div className="grid grid-cols-12 gap-3">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="col-span-6">
<FormLabel>{t("credentials.credentialName")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.credentialName")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem className="col-span-6">
<FormLabel>{t("credentials.username")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.username")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<FormLabel className="mb-2 mt-4 font-bold">
{t("credentials.organization")}
</FormLabel>
<div className="grid grid-cols-26 gap-3">
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem className="col-span-10">
<FormLabel>{t("credentials.description")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.description")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="folder"
render={({ field }) => (
<FormItem className="col-span-10 relative">
<FormLabel>{t("credentials.folder")}</FormLabel>
<FormControl>
<Input
ref={folderInputRef}
placeholder={t("placeholders.folder")}
className="min-h-[40px]"
autoComplete="off"
value={field.value}
onFocus={() => setFolderDropdownOpen(true)}
onChange={(e) => {
field.onChange(e);
setFolderDropdownOpen(true);
}}
/>
</FormControl>
{folderDropdownOpen && filteredFolders.length > 0 && (
<div
ref={folderDropdownRef}
className="absolute top-full left-0 z-50 mt-1 w-full bg-canvas border border-input rounded-md shadow-lg max-h-40 overflow-y-auto thin-scrollbar p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{filteredFolders.map((folder) => (
<Button
key={folder}
type="button"
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)}
>
{folder}
</Button>
))}
</div>
</div>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem className="col-span-10 overflow-visible">
<FormLabel>{t("credentials.tags")}</FormLabel>
<FormControl>
<div className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-field focus-within:ring-2 ring-ring min-h-[40px]">
{(field.value || []).map(
(tag: string, idx: number) => (
<span
key={`${tag}-${idx}`}
className="flex items-center bg-surface text-foreground rounded-full px-2 py-0.5 text-xs"
>
{tag}
<button
type="button"
className="ml-1 text-foreground-subtle hover:text-red-500 focus:outline-none"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const newTags = (
field.value || []
).filter(
(_: string, i: number) => i !== idx,
);
field.onChange(newTags);
}}
>
×
</button>
</span>
),
)}
<input
type="text"
className="flex-1 min-w-[60px] border-none outline-none bg-transparent text-foreground placeholder:text-muted-foreground p-0 h-6 text-sm"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
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")}
/>
</div>
</FormControl>
</FormItem>
)}
/>
</div>
<CredentialGeneralTab
form={form}
folders={folders}
tagInput={tagInput}
setTagInput={setTagInput}
folderDropdownOpen={folderDropdownOpen}
setFolderDropdownOpen={setFolderDropdownOpen}
folderInputRef={folderInputRef}
folderDropdownRef={folderDropdownRef}
filteredFolders={filteredFolders}
handleFolderClick={handleFolderClick}
/>
</TabsContent>
<TabsContent value="authentication">
<FormLabel className="mb-2 font-bold">
{t("credentials.authentication")}
</FormLabel>
<Tabs
value={authTab}
onValueChange={(value) => {
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"
>
<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"
>
{t("credentials.password")}
</TabsTrigger>
<TabsTrigger
value="key"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("credentials.key")}
</TabsTrigger>
</TabsList>
<TabsContent value="password">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t("credentials.password")}</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.password")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="key">
<div className="mt-2">
<div className="mb-3 p-3 border border-muted rounded-md">
<FormLabel className="mb-2 font-bold block">
{t("credentials.generateKeyPair")}
</FormLabel>
<div className="mb-2">
<div className="text-sm text-muted-foreground">
{t("credentials.generateKeyPairDescription")}
</div>
</div>
<div className="flex gap-2 flex-wrap">
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
try {
const currentKeyPassword =
form.watch("keyPassword");
const result = await generateKeyPair(
"ssh-ed25519",
undefined,
currentKeyPassword,
);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
debouncedKeyDetection(
result.privateKey,
currentKeyPassword,
);
debouncedPublicKeyDetection(result.publicKey);
toast.success(
t(
"credentials.keyPairGeneratedSuccessfully",
{ keyType: "Ed25519" },
),
);
} else {
toast.error(
result.error ||
t("credentials.failedToGenerateKeyPair"),
);
}
} catch (error) {
console.error(
"Failed to generate Ed25519 key pair:",
error,
);
toast.error(
t("credentials.failedToGenerateKeyPair"),
);
}
}}
>
{t("credentials.generateEd25519")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
try {
const currentKeyPassword =
form.watch("keyPassword");
const result = await generateKeyPair(
"ecdsa-sha2-nistp256",
undefined,
currentKeyPassword,
);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
debouncedKeyDetection(
result.privateKey,
currentKeyPassword,
);
debouncedPublicKeyDetection(result.publicKey);
toast.success(
t(
"credentials.keyPairGeneratedSuccessfully",
{ keyType: "ECDSA" },
),
);
} else {
toast.error(
result.error ||
t("credentials.failedToGenerateKeyPair"),
);
}
} catch (error) {
console.error(
"Failed to generate ECDSA key pair:",
error,
);
toast.error(
t("credentials.failedToGenerateKeyPair"),
);
}
}}
>
{t("credentials.generateECDSA")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
try {
const currentKeyPassword =
form.watch("keyPassword");
const result = await generateKeyPair(
"ssh-rsa",
2048,
currentKeyPassword,
);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
debouncedKeyDetection(
result.privateKey,
currentKeyPassword,
);
debouncedPublicKeyDetection(result.publicKey);
toast.success(
t(
"credentials.keyPairGeneratedSuccessfully",
{ keyType: "RSA" },
),
);
} else {
toast.error(
result.error ||
t("credentials.failedToGenerateKeyPair"),
);
}
} catch (error) {
console.error(
"Failed to generate RSA key pair:",
error,
);
toast.error(
t("credentials.failedToGenerateKeyPair"),
);
}
}}
>
{t("credentials.generateRSA")}
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 items-start">
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="mb-3 flex flex-col">
<FormLabel className="mb-1 min-h-[20px]">
{t("credentials.sshPrivateKey")}
</FormLabel>
<div className="mb-1">
<div className="relative inline-block w-full">
<input
id="key-upload"
type="file"
accept="*,.pem,.key,.txt,.ppk"
onChange={async (e) => {
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"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span className="truncate">
{t("credentials.uploadPrivateKeyFile")}
</span>
</Button>
</div>
</div>
<FormControl>
<CodeMirror
value={
typeof field.value === "string"
? field.value
: ""
}
onChange={(value) => {
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)",
},
}),
]}
/>
</FormControl>
{detectedKeyType && (
<div className="text-sm mt-2">
<span className="text-muted-foreground">
{t("credentials.detectedKeyType")}:{" "}
</span>
<span
className={`font-medium ${
detectedKeyType === "invalid" ||
detectedKeyType === "error"
? "text-destructive"
: "text-green-600"
}`}
>
{getFriendlyKeyTypeName(detectedKeyType)}
</span>
{keyDetectionLoading && (
<span className="ml-2 text-muted-foreground">
({t("credentials.detectingKeyType")})
</span>
)}
</div>
)}
</FormItem>
)}
/>
<Controller
control={form.control}
name="publicKey"
render={({ field }) => (
<FormItem className="mb-3 flex flex-col">
<FormLabel className="mb-1 min-h-[20px]">
{t("credentials.sshPublicKey")}
</FormLabel>
<div className="mb-1 flex gap-2">
<div className="relative inline-block flex-1">
<input
id="public-key-upload"
type="file"
accept="*,.pub,.txt"
onChange={async (e) => {
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"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span className="truncate">
{t("credentials.uploadPublicKeyFile")}
</span>
</Button>
</div>
<Button
type="button"
variant="outline"
className="flex-shrink-0"
onClick={async () => {
const privateKey = form.watch("key");
if (
!privateKey ||
typeof privateKey !== "string" ||
!privateKey.trim()
) {
toast.error(
t(
"credentials.privateKeyRequiredForGeneration",
),
);
return;
}
try {
const keyPassword =
form.watch("keyPassword");
const result =
await generatePublicKeyFromPrivate(
privateKey,
keyPassword,
);
if (result.success && result.publicKey) {
field.onChange(result.publicKey);
debouncedPublicKeyDetection(
result.publicKey,
);
toast.success(
t(
"credentials.publicKeyGeneratedSuccessfully",
),
);
} else {
toast.error(
result.error ||
t(
"credentials.failedToGeneratePublicKey",
),
);
}
} catch (error) {
console.error(
"Failed to generate public key:",
error,
);
toast.error(
t(
"credentials.failedToGeneratePublicKey",
),
);
}
}}
>
{t("credentials.generatePublicKey")}
</Button>
</div>
<FormControl>
<CodeMirror
value={field.value || ""}
onChange={(value) => {
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)",
},
}),
]}
/>
</FormControl>
{detectedPublicKeyType && field.value && (
<div className="text-sm mt-2">
<span className="text-muted-foreground">
{t("credentials.detectedKeyType")}:{" "}
</span>
<span
className={`font-medium ${
detectedPublicKeyType === "invalid" ||
detectedPublicKeyType === "error"
? "text-destructive"
: "text-green-600"
}`}
>
{getFriendlyKeyTypeName(
detectedPublicKeyType,
)}
</span>
{publicKeyDetectionLoading && (
<span className="ml-2 text-muted-foreground">
({t("credentials.detectingKeyType")})
</span>
)}
</div>
)}
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-8 gap-3 mt-3">
<FormField
control={form.control}
name="keyPassword"
render={({ field }) => (
<FormItem className="col-span-8">
<FormLabel>
{t("credentials.keyPassword")}
</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.keyPassword")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
</TabsContent>
</Tabs>
<CredentialAuthenticationTab
form={form}
authTab={authTab}
setAuthTab={setAuthTab}
detectedKeyType={detectedKeyType}
setDetectedKeyType={setDetectedKeyType}
keyDetectionLoading={keyDetectionLoading}
setKeyDetectionLoading={setKeyDetectionLoading}
detectedPublicKeyType={detectedPublicKeyType}
setDetectedPublicKeyType={setDetectedPublicKeyType}
publicKeyDetectionLoading={publicKeyDetectionLoading}
setPublicKeyDetectionLoading={setPublicKeyDetectionLoading}
keyDetectionTimeoutRef={keyDetectionTimeoutRef}
publicKeyDetectionTimeoutRef={publicKeyDetectionTimeoutRef}
editorTheme={editorTheme}
debouncedKeyDetection={debouncedKeyDetection}
debouncedPublicKeyDetection={debouncedPublicKeyDetection}
getFriendlyKeyTypeName={getFriendlyKeyTypeName}
/>
</TabsContent>
</Tabs>
</ScrollArea>

View File

@@ -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<any>;
watch: UseFormWatch<any>;
setValue: UseFormSetValue<any>;
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 (
<>
<FormLabel className="mb-2 font-bold">
@@ -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({
</TabsList>
<TabsContent value="password">
<FormField
control={control}
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
@@ -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({
</div>
<div className="grid grid-cols-2 gap-3 items-start">
<Controller
control={control}
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="mb-3 flex flex-col">
@@ -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({
)}
/>
<Controller
control={control}
control={form.control}
name="publicKey"
render={({ field }) => (
<FormItem className="mb-3 flex flex-col">
@@ -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({
</div>
<div className="grid grid-cols-8 gap-3 mt-3">
<FormField
control={control}
control={form.control}
name="keyPassword"
render={({ field }) => (
<FormItem className="col-span-8">

View File

@@ -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<any>;
watch: UseFormWatch<any>;
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<HTMLInputElement>(null);
const folderDropdownRef = useRef<HTMLDivElement>(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({
</FormLabel>
<div className="grid grid-cols-12 gap-3">
<FormField
control={control}
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="col-span-6">
@@ -89,7 +47,7 @@ export function CredentialGeneralTab({
/>
<FormField
control={control}
control={form.control}
name="username"
render={({ field }) => (
<FormItem className="col-span-6">
@@ -106,7 +64,7 @@ export function CredentialGeneralTab({
</FormLabel>
<div className="grid grid-cols-26 gap-3">
<FormField
control={control}
control={form.control}
name="description"
render={({ field }) => (
<FormItem className="col-span-10">
@@ -119,7 +77,7 @@ export function CredentialGeneralTab({
/>
<FormField
control={control}
control={form.control}
name="folder"
render={({ field }) => (
<FormItem className="col-span-10 relative">
@@ -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}
</Button>
@@ -166,7 +122,7 @@ export function CredentialGeneralTab({
/>
<FormField
control={control}
control={form.control}
name="tags"
render={({ field }) => (
<FormItem className="col-span-10 overflow-visible">

View File

@@ -0,0 +1,35 @@
import type { UseFormReturn } from "react-hook-form";
import type React from "react";
export interface CredentialGeneralTabProps {
form: UseFormReturn<FormData>;
folders: string[];
tagInput: string;
setTagInput: (value: string) => void;
folderDropdownOpen: boolean;
setFolderDropdownOpen: (value: boolean) => void;
folderInputRef: React.RefObject<HTMLInputElement>;
folderDropdownRef: React.RefObject<HTMLDivElement>;
filteredFolders: string[];
handleFolderClick: (folder: string) => void;
}
export interface CredentialAuthenticationTabProps {
form: UseFormReturn<FormData>;
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<NodeJS.Timeout | null>;
publicKeyDetectionTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
editorTheme: unknown;
debouncedKeyDetection: (keyValue: string, keyPassword?: string) => void;
debouncedPublicKeyDetection: (publicKeyValue: string) => void;
getFriendlyKeyTypeName: (keyType: string) => string;
}

View File

@@ -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;

View File

@@ -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<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);
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 (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("rbac.saveHostFirst")}</AlertTitle>
<AlertDescription>
{t("rbac.saveHostFirstDescription")}
</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-6">
{!hostData?.credentialId && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("rbac.credentialRequired")}</AlertTitle>
<AlertDescription>
{t("rbac.credentialRequiredDescription")}
</AlertDescription>
</Alert>
)}
{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>
<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>
<div className="space-y-2">
<label>{t("rbac.permissionLevel")}</label>
<div className="text-sm text-muted-foreground">
{t("rbac.view")} - {t("rbac.viewDesc")}
</div>
</div>
<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>
<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 className="text-right">
{t("common.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground"
>
{t("common.loading")}
</TableCell>
</TableRow>
) : accessList.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
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 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>
);
}
interface SSHManagerHostEditorProps {
editingHost?: SSHHost | null;
onFormSubmit?: (updatedHost?: SSHHost) => void;
@@ -2322,7 +2855,28 @@ export function HostManagerEditor({
</Accordion>
</TabsContent>
<TabsContent value="terminal">
<TabsContent value="terminal" className="space-y-1">
<FormField
control={form.control}
name="enableTerminal"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.enableTerminal")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t("hosts.enableTerminalDesc")}
</FormDescription>
</FormItem>
)}
/>
<h1 className="text-xl font-semibold mt-7">
{t("hosts.terminalCustomization")}
</h1>
<Accordion
type="multiple"
className="w-full"
@@ -2333,6 +2887,124 @@ export function HostManagerEditor({
{t("hosts.appearance")}
</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<div className="space-y-2">
<label className="text-sm font-medium">
{t("hosts.themePreview")}
</label>
<TerminalPreview
theme={form.watch("terminalConfig.theme")}
fontSize={form.watch("terminalConfig.fontSize")}
fontFamily={form.watch("terminalConfig.fontFamily")}
cursorStyle={form.watch(
"terminalConfig.cursorStyle",
)}
cursorBlink={form.watch(
"terminalConfig.cursorBlink",
)}
letterSpacing={form.watch(
"terminalConfig.letterSpacing",
)}
lineHeight={form.watch("terminalConfig.lineHeight")}
/>
</div>
<FormField
control={form.control}
name="terminalConfig.theme"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.theme")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t("hosts.selectTheme")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(TERMINAL_THEMES).map(
([key, theme]) => (
<SelectItem key={key} value={key}>
{theme.name}
</SelectItem>
),
)}
</SelectContent>
</Select>
<FormDescription>
{t("hosts.chooseColorTheme")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.fontFamily"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.fontFamily")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t("hosts.selectFont")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{TERMINAL_FONTS.map((font) => (
<SelectItem
key={font.value}
value={font.value}
>
{font.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
{t("hosts.selectFontDesc")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.fontSize"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.fontSizeValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={8}
max={24}
step={1}
value={[field.value]}
onValueChange={([value]) =>
field.onChange(value)
}
/>
</FormControl>
<FormDescription>
{t("hosts.adjustFontSize")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.letterSpacing"
@@ -3772,7 +4444,7 @@ export function HostManagerEditor({
</TabsContent>
<TabsContent value="sharing" className="space-y-6">
<HostSharingTab
<SharingTabContent
hostId={editingHost?.id}
isNewHost={!editingHost?.id}
/>

View File

@@ -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<HTMLButtonElement>(null);
const keyTypeDropdownRef = useRef<HTMLDivElement>(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 (
<Tabs
value={authTab}
onValueChange={(value) => {
const newAuthType = value as "password" | "key" | "credential" | "none";
setAuthTab(newAuthType);
setValue("authType", newAuthType);
}}
className="flex-1 flex flex-col h-full min-h-0"
>
<TabsList className="bg-button border border-edge-medium">
<TabsTrigger
value="password"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.password")}
</TabsTrigger>
<TabsTrigger
value="key"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.key")}
</TabsTrigger>
<TabsTrigger
value="credential"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.credential")}
</TabsTrigger>
<TabsTrigger
value="none"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.none")}
</TabsTrigger>
</TabsList>
<TabsContent value="password">
<FormField
control={control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.password")}</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.password")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="key">
<Tabs
value={keyInputMethod}
onValueChange={(value) => {
setKeyInputMethod(value as "upload" | "paste");
if (value === "upload") {
setValue("key", null);
} else {
setValue("key", "");
}
}}
className="w-full"
>
<TabsList className="inline-flex items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
<TabsTrigger value="upload">{t("hosts.uploadFile")}</TabsTrigger>
<TabsTrigger value="paste">{t("hosts.pasteKey")}</TabsTrigger>
</TabsList>
<TabsContent value="upload" className="mt-4">
<Controller
control={control}
name="key"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>{t("hosts.sshPrivateKey")}</FormLabel>
<FormControl>
<div className="relative inline-block">
<input
id="key-upload"
type="file"
accept=".pem,.key,.txt,.ppk"
onChange={(e) => {
const file = e.target.files?.[0];
field.onChange(file || null);
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="justify-start text-left"
>
<span
className="truncate"
title={
(field.value as File)?.name || t("hosts.upload")
}
>
{field.value === "existing_key"
? t("hosts.existingKey")
: field.value
? editingHost
? t("hosts.updateKey")
: (field.value as File).name
: t("hosts.upload")}
</span>
</Button>
</div>
</FormControl>
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="paste" className="mt-4">
<Controller
control={control}
name="key"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>{t("hosts.sshPrivateKey")}</FormLabel>
<FormControl>
<CodeMirror
value={typeof field.value === "string" ? field.value : ""}
onChange={(value) => 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)",
},
}),
]}
/>
</FormControl>
</FormItem>
)}
/>
</TabsContent>
</Tabs>
<div className="grid grid-cols-15 gap-4 mt-4">
<FormField
control={control}
name="keyPassword"
render={({ field }) => (
<FormItem className="col-span-8">
<FormLabel>{t("hosts.keyPassword")}</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.keyPassword")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name="keyType"
render={({ field }) => (
<FormItem className="relative col-span-3">
<FormLabel>{t("hosts.keyType")}</FormLabel>
<FormControl>
<div className="relative">
<Button
ref={keyTypeButtonRef}
type="button"
variant="outline"
className="w-full justify-start text-left rounded-md px-2 py-2 bg-canvas border border-input text-foreground"
onClick={() => setKeyTypeDropdownOpen((open) => !open)}
>
{keyTypeOptions.find((opt) => opt.value === field.value)
?.label || t("hosts.autoDetect")}
</Button>
{keyTypeDropdownOpen && (
<div
ref={keyTypeDropdownRef}
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-canvas border border-input rounded-md shadow-lg max-h-40 overflow-y-auto thin-scrollbar p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{keyTypeOptions.map((opt) => (
<Button
key={opt.value}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-canvas text-foreground hover:bg-surface-hover focus:bg-surface-hover focus:outline-none"
onClick={() => {
field.onChange(opt.value);
setKeyTypeDropdownOpen(false);
}}
>
{opt.label}
</Button>
))}
</div>
</div>
)}
</div>
</FormControl>
</FormItem>
)}
/>
</div>
</TabsContent>
<TabsContent value="credential">
<div className="space-y-4">
<FormField
control={control}
name="credentialId"
render={({ field }) => (
<FormItem>
<CredentialSelector
value={field.value}
onValueChange={field.onChange}
onCredentialSelect={(credential) => {
if (credential && !watch("overrideCredentialUsername")) {
setValue("username", credential.username);
}
}}
/>
<FormDescription>
{t("hosts.credentialDescription")}
</FormDescription>
</FormItem>
)}
/>
{watch("credentialId") && (
<FormField
control={control}
name="overrideCredentialUsername"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>
{t("hosts.overrideCredentialUsername")}
</FormLabel>
<FormDescription>
{t("hosts.overrideCredentialUsernameDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
</div>
</TabsContent>
<TabsContent value="none">
<Alert className="mt-2">
<AlertDescription>
<strong>{t("hosts.noneAuthTitle")}</strong>
<div className="mt-2">{t("hosts.noneAuthDescription")}</div>
<div className="mt-2 text-sm">{t("hosts.noneAuthDetails")}</div>
</AlertDescription>
</Alert>
</TabsContent>
</Tabs>
);
}

View File

@@ -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 (
<div className="space-y-4">
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
onClick={() => window.open("https://docs.termix.site/docker", "_blank")}
>
{t("common.documentation")}
</Button>
<FormField
control={control}
name="enableDocker"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.enableDocker")}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormDescription>{t("hosts.enableDockerDesc")}</FormDescription>
</FormItem>
)}
/>
</div>
);
}

View File

@@ -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 (
<div className="space-y-4">
<FormField
control={control}
name="enableFileManager"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.enableFileManager")}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormDescription>
{t("hosts.enableFileManagerDesc")}
</FormDescription>
</FormItem>
)}
/>
{watch("enableFileManager") && (
<div className="mt-4">
<FormField
control={control}
name="defaultPath"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.defaultPath")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.homePath")}
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
<FormDescription>{t("hosts.defaultPathDesc")}</FormDescription>
</FormItem>
)}
/>
</div>
)}
</div>
);
}

View File

@@ -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<HTMLInputElement>(null);
const folderDropdownRef = useRef<HTMLDivElement>(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 (
<>
<FormLabel className="mb-3 font-bold">
{t("hosts.connectionDetails")}
</FormLabel>
<div className="grid grid-cols-12 gap-4">
<FormField
control={control}
name="ip"
render={({ field }) => (
<FormItem className="col-span-5">
<FormLabel>{t("hosts.ipAddress")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.ipAddress")}
{...field}
ref={(e) => {
field.ref(e);
if (ipInputRef?.current) {
ipInputRef.current = e;
}
}}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name="port"
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>{t("hosts.port")}</FormLabel>
<FormControl>
<Input placeholder={t("placeholders.port")} {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name="username"
render={({ field }) => {
const isCredentialAuth = authTab === "credential";
const hasCredential = !!watch("credentialId");
const overrideEnabled = !!watch("overrideCredentialUsername");
const shouldDisable =
isCredentialAuth && hasCredential && !overrideEnabled;
return (
<FormItem className="col-span-6">
<FormLabel>{t("hosts.username")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.username")}
disabled={shouldDisable}
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
</FormItem>
);
}}
/>
</div>
<FormLabel className="mb-3 mt-3 font-bold">
{t("hosts.organization")}
</FormLabel>
<div className="grid grid-cols-26 gap-4">
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem className="col-span-10">
<FormLabel>{t("hosts.name")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.hostname")}
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name="folder"
render={({ field }) => (
<FormItem className="col-span-10 relative">
<FormLabel>{t("hosts.folder")}</FormLabel>
<FormControl>
<Input
ref={folderInputRef}
placeholder={t("placeholders.folder")}
className="min-h-[40px]"
autoComplete="off"
value={field.value}
onFocus={() => setFolderDropdownOpen(true)}
onChange={(e) => {
field.onChange(e);
setFolderDropdownOpen(true);
}}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
{folderDropdownOpen && filteredFolders.length > 0 && (
<div
ref={folderDropdownRef}
className="absolute top-full left-0 z-50 mt-1 w-full bg-canvas border border-input rounded-md shadow-lg max-h-40 overflow-y-auto thin-scrollbar p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{filteredFolders.map((folder) => (
<Button
key={folder}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded px-2 py-1.5 hover:bg-surface-hover focus:bg-surface-hover focus:outline-none"
onClick={() => handleFolderClick(folder)}
>
{folder}
</Button>
))}
</div>
</div>
)}
</FormItem>
)}
/>
<FormField
control={control}
name="tags"
render={({ field }) => (
<FormItem className="col-span-10 overflow-visible">
<FormLabel>{t("hosts.tags")}</FormLabel>
<FormControl>
<div className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-field focus-within:ring-2 ring-ring min-h-[40px]">
{field.value.map((tag: string, idx: number) => (
<span
key={tag + idx}
className="flex items-center bg-surface text-foreground rounded-full px-2 py-0.5 text-xs"
>
{tag}
<button
type="button"
className="ml-1 text-foreground-subtle hover:text-red-500 focus:outline-none"
onClick={() => {
const newTags = field.value.filter(
(_: string, i: number) => i !== idx,
);
field.onChange(newTags);
}}
>
×
</button>
</span>
))}
<input
type="text"
className="flex-1 min-w-[60px] border-none outline-none bg-transparent text-foreground placeholder:text-muted-foreground p-0 h-6"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
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")}
/>
</div>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name="pin"
render={({ field }) => (
<FormItem className="col-span-6">
<FormLabel>{t("hosts.pin")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name="notes"
render={({ field }) => (
<FormItem className="col-span-26">
<FormLabel>{t("hosts.notes")}</FormLabel>
<FormControl>
<Textarea
placeholder={t("placeholders.notes")}
className="resize-none"
rows={3}
value={field.value || ""}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<FormLabel className="mb-3 mt-3 font-bold">
{t("hosts.authentication")}
</FormLabel>
<HostAuthenticationSection
control={control}
watch={watch}
setValue={setValue}
credentials={credentials}
authTab={authTab}
setAuthTab={setAuthTab}
keyInputMethod={keyInputMethod}
setKeyInputMethod={setKeyInputMethod}
editorTheme={editorTheme}
editingHost={editingHost}
t={t}
/>
<Separator className="my-6" />
<Accordion type="multiple" className="w-full">
<AccordionItem value="advanced-auth">
<AccordionTrigger>{t("hosts.advancedAuthSettings")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<FormField
control={control}
name="forceKeyboardInteractive"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.forceKeyboardInteractive")}</FormLabel>
<FormDescription>
{t("hosts.forceKeyboardInteractiveDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="jump-hosts">
<AccordionTrigger>{t("hosts.jumpHosts")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<Alert>
<AlertDescription>
{t("hosts.jumpHostsDescription")}
</AlertDescription>
</Alert>
<FormField
control={control}
name="jumpHosts"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.jumpHostChain")}</FormLabel>
<FormControl>
<div className="space-y-3">
{field.value.map((jumpHost, index) => (
<JumpHostItem
key={index}
jumpHost={jumpHost}
index={index}
hosts={hosts}
editingHost={editingHost}
onUpdate={(hostId) => {
const newJumpHosts = [...field.value];
newJumpHosts[index] = { hostId };
field.onChange(newJumpHosts);
}}
onRemove={() => {
const newJumpHosts = field.value.filter(
(_, i) => i !== index,
);
field.onChange(newJumpHosts);
}}
t={t}
/>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
field.onChange([...field.value, { hostId: 0 }]);
}}
>
<Plus className="h-4 w-4 mr-2" />
{t("hosts.addJumpHost")}
</Button>
</div>
</FormControl>
<FormDescription>{t("hosts.jumpHostsOrder")}</FormDescription>
</FormItem>
)}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="socks5">
<AccordionTrigger>{t("hosts.socks5Proxy")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<Alert>
<AlertDescription>
{t("hosts.socks5Description")}
</AlertDescription>
</Alert>
<FormField
control={control}
name="useSocks5"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>{t("hosts.enableSocks5")}</FormLabel>
<FormDescription>
{t("hosts.enableSocks5Description")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{watch("useSocks5") && (
<div className="space-y-4">
<div className="space-y-2">
<FormLabel>{t("hosts.socks5ProxyMode")}</FormLabel>
<div className="flex gap-2">
<Button
type="button"
variant={proxyMode === "single" ? "default" : "outline"}
onClick={() => setProxyMode("single")}
className="flex-1"
>
{t("hosts.socks5UseSingleProxy")}
</Button>
<Button
type="button"
variant={proxyMode === "chain" ? "default" : "outline"}
onClick={() => setProxyMode("chain")}
className="flex-1"
>
{t("hosts.socks5UseProxyChain")}
</Button>
</div>
</div>
{proxyMode === "single" && (
<div className="space-y-4 p-4 border rounded-lg">
<FormField
control={control}
name="socks5Host"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.socks5Host")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.socks5Host")}
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
<FormDescription>
{t("hosts.socks5HostDescription")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={control}
name="socks5Port"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.socks5Port")}</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t("placeholders.socks5Port")}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 1080)
}
/>
</FormControl>
<FormDescription>
{t("hosts.socks5PortDescription")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={control}
name="socks5Username"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.socks5Username")} {t("hosts.optional")}
</FormLabel>
<FormControl>
<Input
placeholder={t("hosts.username")}
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name="socks5Password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.socks5Password")} {t("hosts.optional")}
</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("hosts.password")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</div>
)}
{proxyMode === "chain" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<FormLabel>{t("hosts.socks5ProxyChain")}</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const currentChain = watch("socks5ProxyChain") || [];
setValue("socks5ProxyChain", [
...currentChain,
{
host: "",
port: 1080,
type: 5 as 4 | 5,
username: "",
password: "",
},
]);
}}
>
<Plus className="h-4 w-4 mr-2" />
{t("hosts.addProxyNode")}
</Button>
</div>
{(watch("socks5ProxyChain") || []).length === 0 && (
<div className="text-sm text-muted-foreground text-center p-4 border rounded-lg border-dashed">
{t("hosts.noProxyNodes")}
</div>
)}
{(watch("socks5ProxyChain") || []).map(
(node: any, index: number) => (
<div
key={index}
className="p-4 border rounded-lg space-y-3 relative"
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">
{t("hosts.proxyNode")} {index + 1}
</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
const currentChain =
watch("socks5ProxyChain") || [];
setValue(
"socks5ProxyChain",
currentChain.filter(
(_: any, i: number) => i !== index,
),
);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<FormLabel>{t("hosts.socks5Host")}</FormLabel>
<Input
placeholder={t("placeholders.socks5Host")}
value={node.host}
onChange={(e) => {
const currentChain =
watch("socks5ProxyChain") || [];
const newChain = [...currentChain];
newChain[index] = {
...newChain[index],
host: e.target.value,
};
setValue("socks5ProxyChain", newChain);
}}
onBlur={(e) => {
const currentChain =
watch("socks5ProxyChain") || [];
const newChain = [...currentChain];
newChain[index] = {
...newChain[index],
host: e.target.value.trim(),
};
setValue("socks5ProxyChain", newChain);
}}
/>
</div>
<div className="space-y-2">
<FormLabel>{t("hosts.socks5Port")}</FormLabel>
<Input
type="number"
placeholder={t("placeholders.socks5Port")}
value={node.port}
onChange={(e) => {
const currentChain =
watch("socks5ProxyChain") || [];
const newChain = [...currentChain];
newChain[index] = {
...newChain[index],
port: parseInt(e.target.value) || 1080,
};
setValue("socks5ProxyChain", newChain);
}}
/>
</div>
</div>
<div className="space-y-2">
<FormLabel>{t("hosts.proxyType")}</FormLabel>
<Select
value={String(node.type)}
onValueChange={(value) => {
const currentChain =
watch("socks5ProxyChain") || [];
const newChain = [...currentChain];
newChain[index] = {
...newChain[index],
type: parseInt(value) as 4 | 5,
};
setValue("socks5ProxyChain", newChain);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="4">
{t("hosts.socks4")}
</SelectItem>
<SelectItem value="5">
{t("hosts.socks5")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<FormLabel>
{t("hosts.socks5Username")}{" "}
{t("hosts.optional")}
</FormLabel>
<Input
placeholder={t("hosts.username")}
value={node.username || ""}
onChange={(e) => {
const currentChain =
watch("socks5ProxyChain") || [];
const newChain = [...currentChain];
newChain[index] = {
...newChain[index],
username: e.target.value,
};
setValue("socks5ProxyChain", newChain);
}}
onBlur={(e) => {
const currentChain =
watch("socks5ProxyChain") || [];
const newChain = [...currentChain];
newChain[index] = {
...newChain[index],
username: e.target.value.trim(),
};
setValue("socks5ProxyChain", newChain);
}}
/>
</div>
<div className="space-y-2">
<FormLabel>
{t("hosts.socks5Password")}{" "}
{t("hosts.optional")}
</FormLabel>
<PasswordInput
placeholder={t("hosts.password")}
value={node.password || ""}
onChange={(e) => {
const currentChain =
watch("socks5ProxyChain") || [];
const newChain = [...currentChain];
newChain[index] = {
...newChain[index],
password: e.target.value,
};
setValue("socks5ProxyChain", newChain);
}}
/>
</div>
</div>
</div>
),
)}
</div>
)}
</div>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</>
);
}

View File

@@ -1,588 +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.ts";
interface HostSharingTabProps {
hostId: number | undefined;
isNewHost: boolean;
}
interface User {
id: string;
username: string;
is_admin: boolean;
}
// Only view permission is supported (manage removed due to encryption constraints)
const PERMISSION_LEVELS = [{ value: "view", labelKey: "rbac.view" }];
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 - Always "view" (read-only) */}
<div className="space-y-2">
<Label>{t("rbac.permissionLevel")}</Label>
<div className="text-sm text-muted-foreground">
{t("rbac.view")} - {t("rbac.viewDesc")}
</div>
</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 className="text-right">
{t("common.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground"
>
{t("common.loading")}
</TableCell>
</TableRow>
) : accessList.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
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 className="text-right">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => handleRevoke(access.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</>
)}
</div>
);
}

View File

@@ -1,284 +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 { Button } from "@/components/ui/button.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import { Checkbox } from "@/components/ui/checkbox.tsx";
import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
import { Plus } from "lucide-react";
import { QuickActionItem } from "./shared/QuickActionItem.tsx";
import type { HostStatisticsTabProps } from "./shared/tab-types";
export function HostStatisticsTab({
control,
watch,
setValue,
statusIntervalUnit,
setStatusIntervalUnit,
metricsIntervalUnit,
setMetricsIntervalUnit,
t,
}: HostStatisticsTabProps) {
// For quick actions - need to get snippets from parent
const snippets = watch("snippets") || [];
return (
<div className="space-y-6">
<div className="space-y-4">
<div className="space-y-3">
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
onClick={() =>
window.open("https://docs.termix.site/server-stats", "_blank")
}
>
{t("common.documentation")}
</Button>
<FormField
control={control}
name="statsConfig.statusCheckEnabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.statusCheckEnabled")}</FormLabel>
<FormDescription>
{t("hosts.statusCheckEnabledDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{watch("statsConfig.statusCheckEnabled") && (
<FormField
control={control}
name="statsConfig.statusCheckInterval"
render={({ field }) => {
const displayValue =
statusIntervalUnit === "minutes"
? Math.round((field.value || 30) / 60)
: field.value || 30;
const handleIntervalChange = (value: string) => {
const numValue = parseInt(value) || 0;
const seconds =
statusIntervalUnit === "minutes" ? numValue * 60 : numValue;
field.onChange(seconds);
};
return (
<FormItem>
<FormLabel>{t("hosts.statusCheckInterval")}</FormLabel>
<div className="flex gap-2">
<FormControl>
<Input
type="number"
value={displayValue}
onChange={(e) => handleIntervalChange(e.target.value)}
className="flex-1"
/>
</FormControl>
<Select
value={statusIntervalUnit}
onValueChange={(value: "seconds" | "minutes") => {
setStatusIntervalUnit(value);
const currentSeconds = field.value || 30;
if (value === "minutes") {
const minutes = Math.round(currentSeconds / 60);
field.onChange(minutes * 60);
}
}}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="seconds">
{t("hosts.intervalSeconds")}
</SelectItem>
<SelectItem value="minutes">
{t("hosts.intervalMinutes")}
</SelectItem>
</SelectContent>
</Select>
</div>
<FormDescription>
{t("hosts.statusCheckIntervalDesc")}
</FormDescription>
</FormItem>
);
}}
/>
)}
</div>
<div className="space-y-3">
<FormField
control={control}
name="statsConfig.metricsEnabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.metricsEnabled")}</FormLabel>
<FormDescription>
{t("hosts.metricsEnabledDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{watch("statsConfig.metricsEnabled") && (
<FormField
control={control}
name="statsConfig.metricsInterval"
render={({ field }) => {
const displayValue =
metricsIntervalUnit === "minutes"
? Math.round((field.value || 30) / 60)
: field.value || 30;
const handleIntervalChange = (value: string) => {
const numValue = parseInt(value) || 0;
const seconds =
metricsIntervalUnit === "minutes"
? numValue * 60
: numValue;
field.onChange(seconds);
};
return (
<FormItem>
<FormLabel>{t("hosts.metricsInterval")}</FormLabel>
<div className="flex gap-2">
<FormControl>
<Input
type="number"
value={displayValue}
onChange={(e) => handleIntervalChange(e.target.value)}
className="flex-1"
/>
</FormControl>
<Select
value={metricsIntervalUnit}
onValueChange={(value: "seconds" | "minutes") => {
setMetricsIntervalUnit(value);
const currentSeconds = field.value || 30;
if (value === "minutes") {
const minutes = Math.round(currentSeconds / 60);
field.onChange(minutes * 60);
}
}}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="seconds">
{t("hosts.intervalSeconds")}
</SelectItem>
<SelectItem value="minutes">
{t("hosts.intervalMinutes")}
</SelectItem>
</SelectContent>
</Select>
</div>
<FormDescription>
{t("hosts.metricsIntervalDesc")}
</FormDescription>
</FormItem>
);
}}
/>
)}
</div>
</div>
{watch("statsConfig.metricsEnabled") && (
<>
<FormField
control={control}
name="statsConfig.enabledWidgets"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.enabledWidgets")}</FormLabel>
<FormDescription>
{t("hosts.enabledWidgetsDesc")}
</FormDescription>
<div className="space-y-3 mt-3">
{(
[
"cpu",
"memory",
"disk",
"network",
"uptime",
"processes",
"system",
"login_stats",
] as const
).map((widget) => (
<div key={widget} className="flex items-center space-x-2">
<Checkbox
checked={field.value?.includes(widget)}
onCheckedChange={(checked) => {
const currentWidgets = field.value || [];
if (checked) {
field.onChange([...currentWidgets, widget]);
} else {
field.onChange(
currentWidgets.filter((w) => w !== widget),
);
}
}}
/>
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{widget === "cpu" && t("serverStats.cpuUsage")}
{widget === "memory" && t("serverStats.memoryUsage")}
{widget === "disk" && t("serverStats.diskUsage")}
{widget === "network" &&
t("serverStats.networkInterfaces")}
{widget === "uptime" && t("serverStats.uptime")}
{widget === "processes" && t("serverStats.processes")}
{widget === "system" && t("serverStats.systemInfo")}
{widget === "login_stats" &&
t("serverStats.loginStats")}
</label>
</div>
))}
</div>
</FormItem>
)}
/>
</>
)}
</div>
);
}

View File

@@ -1,759 +0,0 @@
import React from "react";
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 { Button } from "@/components/ui/button.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion.tsx";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover.tsx";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command.tsx";
import { Slider } from "@/components/ui/slider.tsx";
import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
import { cn } from "@/lib/utils.ts";
import type { HostTerminalTabProps } from "./shared/tab-types";
export function HostTerminalTab({
control,
watch,
setValue,
snippets,
t,
}: HostTerminalTabProps) {
return (
<Accordion
type="multiple"
className="w-full"
defaultValue={["appearance", "behavior", "advanced"]}
>
<AccordionItem value="appearance">
<AccordionTrigger>{t("hosts.appearance")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<FormField
control={control}
name="terminalConfig.letterSpacing"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.letterSpacingValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={-2}
max={10}
step={0.5}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
/>
</FormControl>
<FormDescription>
{t("hosts.adjustLetterSpacing")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={control}
name="terminalConfig.lineHeight"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.lineHeightValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={1}
max={2}
step={0.1}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
/>
</FormControl>
<FormDescription>{t("hosts.adjustLineHeight")}</FormDescription>
</FormItem>
)}
/>
<FormField
control={control}
name="terminalConfig.theme"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.terminalTheme")}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("hosts.selectTheme")} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="termix">Termix Default</SelectItem>
<SelectItem value="termixDark">Termix Dark</SelectItem>
<SelectItem value="termixLight">Termix Light</SelectItem>
<SelectItem value="dracula">Dracula</SelectItem>
<SelectItem value="monokai">Monokai</SelectItem>
<SelectItem value="nord">Nord</SelectItem>
<SelectItem value="gruvboxDark">Gruvbox Dark</SelectItem>
<SelectItem value="gruvboxLight">Gruvbox Light</SelectItem>
<SelectItem value="solarizedDark">
Solarized Dark
</SelectItem>
<SelectItem value="solarizedLight">
Solarized Light
</SelectItem>
<SelectItem value="oneDark">One Dark</SelectItem>
<SelectItem value="tokyoNight">Tokyo Night</SelectItem>
<SelectItem value="ayuDark">Ayu Dark</SelectItem>
<SelectItem value="ayuLight">Ayu Light</SelectItem>
<SelectItem value="materialTheme">
Material Theme
</SelectItem>
<SelectItem value="palenight">Palenight</SelectItem>
<SelectItem value="oceanicNext">Oceanic Next</SelectItem>
<SelectItem value="nightOwl">Night Owl</SelectItem>
<SelectItem value="synthwave84">Synthwave '84</SelectItem>
<SelectItem value="cobalt2">Cobalt2</SelectItem>
<SelectItem value="snazzy">Snazzy</SelectItem>
<SelectItem value="atomOneDark">Atom One Dark</SelectItem>
<SelectItem value="catppuccinMocha">
Catppuccin Mocha
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t("hosts.chooseTerminalTheme")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={control}
name="terminalConfig.fontFamily"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.terminalFont")}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("hosts.selectFont")} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Caskaydia Cove Nerd Font Mono">
Caskaydia Cove Nerd Font Mono
</SelectItem>
<SelectItem value="JetBrains Mono">
JetBrains Mono
</SelectItem>
<SelectItem value="Fira Code">Fira Code</SelectItem>
<SelectItem value="Cascadia Code">Cascadia Code</SelectItem>
<SelectItem value="Source Code Pro">
Source Code Pro
</SelectItem>
<SelectItem value="SF Mono">SF Mono</SelectItem>
<SelectItem value="Consolas">Consolas</SelectItem>
<SelectItem value="Monaco">Monaco</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t("hosts.chooseTerminalFont")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={control}
name="terminalConfig.fontSize"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.fontSizeValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={8}
max={24}
step={1}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
/>
</FormControl>
<FormDescription>{t("hosts.adjustFontSize")}</FormDescription>
</FormItem>
)}
/>
<FormField
control={control}
name="terminalConfig.cursorStyle"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.cursorStyle")}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("hosts.selectCursorStyle")} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="block">
{t("hosts.cursorStyleBlock")}
</SelectItem>
<SelectItem value="underline">
{t("hosts.cursorStyleUnderline")}
</SelectItem>
<SelectItem value="bar">
{t("hosts.cursorStyleBar")}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t("hosts.chooseCursorAppearance")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={control}
name="terminalConfig.cursorBlink"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.cursorBlink")}</FormLabel>
<FormDescription>
{t("hosts.enableCursorBlink")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="behavior">
<AccordionTrigger>{t("hosts.behavior")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<FormField
control={control}
name="terminalConfig.scrollback"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.scrollbackBufferValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={1000}
max={100000}
step={1000}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
/>
</FormControl>
<FormDescription>
{t("hosts.scrollbackBufferDesc")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={control}
name="terminalConfig.bellStyle"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.bellStyle")}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("hosts.selectBellStyle")} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">
{t("hosts.bellStyleNone")}
</SelectItem>
<SelectItem value="sound">
{t("hosts.bellStyleSound")}
</SelectItem>
<SelectItem value="visual">
{t("hosts.bellStyleVisual")}
</SelectItem>
<SelectItem value="both">
{t("hosts.bellStyleBoth")}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>{t("hosts.bellStyleDesc")}</FormDescription>
</FormItem>
)}
/>
<FormField
control={control}
name="terminalConfig.rightClickSelectsWord"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.rightClickSelectsWord")}</FormLabel>
<FormDescription>
{t("hosts.rightClickSelectsWordDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name="terminalConfig.fastScrollModifier"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.fastScrollModifier")}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("hosts.selectModifier")} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="alt">
{t("hosts.modifierAlt")}
</SelectItem>
<SelectItem value="ctrl">
{t("hosts.modifierCtrl")}
</SelectItem>
<SelectItem value="shift">
{t("hosts.modifierShift")}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t("hosts.fastScrollModifierDesc")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={control}
name="terminalConfig.fastScrollSensitivity"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.fastScrollSensitivityValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={1}
max={10}
step={1}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
/>
</FormControl>
<FormDescription>
{t("hosts.fastScrollSensitivityDesc")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={control}
name="terminalConfig.minimumContrastRatio"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.minimumContrastRatioValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={1}
max={21}
step={1}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
/>
</FormControl>
<FormDescription>
{t("hosts.minimumContrastRatioDesc")}
</FormDescription>
</FormItem>
)}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="advanced">
<AccordionTrigger>{t("hosts.advanced")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<FormField
control={control}
name="terminalConfig.agentForwarding"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.sshAgentForwarding")}</FormLabel>
<FormDescription>
{t("hosts.sshAgentForwardingDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name="terminalConfig.backspaceMode"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.backspaceMode")}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t("hosts.selectBackspaceMode")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="normal">
{t("hosts.backspaceModeNormal")}
</SelectItem>
<SelectItem value="control-h">
{t("hosts.backspaceModeControlH")}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t("hosts.backspaceModeDesc")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={control}
name="terminalConfig.startupSnippetId"
render={({ field }) => {
const [open, setOpen] = React.useState(false);
const selectedSnippet = snippets.find(
(s) => s.id === field.value,
);
return (
<FormItem>
<FormLabel>{t("hosts.startupSnippet")}</FormLabel>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{selectedSnippet
? selectedSnippet.name
: t("hosts.selectSnippet")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{
width: "var(--radix-popover-trigger-width)",
}}
>
<Command>
<CommandInput placeholder={t("hosts.searchSnippets")} />
<CommandEmpty>{t("hosts.noSnippetFound")}</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto thin-scrollbar">
<CommandItem
value="none"
onSelect={() => {
field.onChange(null);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
!field.value ? "opacity-100" : "opacity-0",
)}
/>
{t("hosts.snippetNone")}
</CommandItem>
{snippets.map((snippet) => (
<CommandItem
key={snippet.id}
value={`${snippet.name} ${snippet.content} ${snippet.id}`}
onSelect={() => {
field.onChange(snippet.id);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value === snippet.id
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">
{snippet.name}
</span>
<span className="text-xs text-muted-foreground truncate max-w-[350px]">
{snippet.content}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
{t("hosts.executeSnippetOnConnect")}
</FormDescription>
</FormItem>
);
}}
/>
<FormField
control={control}
name="terminalConfig.autoMosh"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.autoMosh")}</FormLabel>
<FormDescription>{t("hosts.autoMoshDesc")}</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{watch("terminalConfig.autoMosh") && (
<FormField
control={control}
name="terminalConfig.moshCommand"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.moshCommand")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.moshCommand")}
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
<FormDescription>
{t("hosts.moshCommandDesc")}
</FormDescription>
</FormItem>
)}
/>
)}
<FormField
control={control}
name="terminalConfig.sudoPasswordAutoFill"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>{t("hosts.sudoPasswordAutoFill")}</FormLabel>
<FormDescription>
{t("hosts.sudoPasswordAutoFillDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{watch("terminalConfig.sudoPasswordAutoFill") && (
<FormField
control={control}
name="terminalConfig.sudoPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.sudoPassword")}</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.sudoPassword")}
{...field}
/>
</FormControl>
<FormDescription>
{t("hosts.sudoPasswordDesc")}
</FormDescription>
</FormItem>
)}
/>
)}
<div className="space-y-2">
<label className="text-sm font-medium">
{t("hosts.environmentVariables")}
</label>
<FormDescription>
{t("hosts.environmentVariablesDesc")}
</FormDescription>
{watch("terminalConfig.environmentVariables")?.map((_, index) => (
<div key={index} className="flex gap-2">
<FormField
control={control}
name={`terminalConfig.environmentVariables.${index}.key`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input
placeholder={t("hosts.variableName")}
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name={`terminalConfig.environmentVariables.${index}.value`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input
placeholder={t("hosts.variableValue")}
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
</FormItem>
)}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
const current = watch(
"terminalConfig.environmentVariables",
);
setValue(
"terminalConfig.environmentVariables",
current.filter((_, i) => i !== index),
);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const current =
watch("terminalConfig.environmentVariables") || [];
setValue("terminalConfig.environmentVariables", [
...current,
{ key: "", value: "" },
]);
}}
>
<Plus className="h-4 w-4 mr-2" />
{t("hosts.addVariable")}
</Button>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
);
}

View File

@@ -1,441 +0,0 @@
import React, { useRef, useState, useEffect } from "react";
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 { Button } from "@/components/ui/button.tsx";
import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
import type { HostTunnelTabProps } from "./shared/tab-types";
export function HostTunnelTab({
control,
watch,
setValue,
getValues,
sshConfigurations,
editingHost,
t,
}: HostTunnelTabProps) {
const [sshConfigDropdownOpen, setSshConfigDropdownOpen] = useState<{
[key: number]: boolean;
}>({});
const sshConfigInputRefs = useRef<{
[key: number]: HTMLInputElement | null;
}>({});
const sshConfigDropdownRefs = useRef<{
[key: number]: HTMLDivElement | null;
}>({});
const getFilteredSshConfigs = (index: number) => {
const value = watch(`tunnelConnections.${index}.endpointHost`);
const currentHostId = editingHost?.id;
let filtered = sshConfigurations;
if (currentHostId) {
const currentHostName = editingHost?.name;
if (currentHostName) {
filtered = sshConfigurations.filter(
(config) => config !== currentHostName,
);
}
} else {
const currentHostName =
watch("name") || `${watch("username")}@${watch("ip")}`;
filtered = sshConfigurations.filter(
(config) => config !== currentHostName,
);
}
if (value) {
filtered = filtered.filter((config) =>
config.toLowerCase().includes(value.toLowerCase()),
);
}
return filtered;
};
const handleSshConfigClick = (config: string, index: number) => {
setValue(`tunnelConnections.${index}.endpointHost`, config);
setSshConfigDropdownOpen((prev) => ({ ...prev, [index]: false }));
};
useEffect(() => {
function handleSshConfigClickOutside(event: MouseEvent) {
const openDropdowns = Object.keys(sshConfigDropdownOpen).filter(
(key) => sshConfigDropdownOpen[parseInt(key)],
);
openDropdowns.forEach((indexStr: string) => {
const index = parseInt(indexStr);
if (
sshConfigDropdownRefs.current[index] &&
!sshConfigDropdownRefs.current[index]?.contains(
event.target as Node,
) &&
sshConfigInputRefs.current[index] &&
!sshConfigInputRefs.current[index]?.contains(event.target as Node)
) {
setSshConfigDropdownOpen((prev) => ({ ...prev, [index]: false }));
}
});
}
const hasOpenDropdowns = Object.values(sshConfigDropdownOpen).some(
(open) => open,
);
if (hasOpenDropdowns) {
document.addEventListener("mousedown", handleSshConfigClickOutside);
} else {
document.removeEventListener("mousedown", handleSshConfigClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleSshConfigClickOutside);
};
}, [sshConfigDropdownOpen]);
return (
<div className="space-y-4">
<FormField
control={control}
name="enableTunnel"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.enableTunnel")}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormDescription>{t("hosts.enableTunnelDesc")}</FormDescription>
</FormItem>
)}
/>
{watch("enableTunnel") && (
<>
<Alert className="mt-4">
<AlertDescription>
<strong>{t("hosts.sshpassRequired")}</strong>
<div>
{t("hosts.sshpassRequiredDesc")}{" "}
<code className="bg-muted px-1 rounded inline">
sudo apt install sshpass
</code>{" "}
{t("hosts.debianUbuntuEquivalent")}
</div>
<div className="mt-2">
<strong>{t("hosts.otherInstallMethods")}</strong>
<div>
{t("hosts.centosRhelFedora")}{" "}
<code className="bg-muted px-1 rounded inline">
sudo yum install sshpass
</code>{" "}
{t("hosts.or")}{" "}
<code className="bg-muted px-1 rounded inline">
sudo dnf install sshpass
</code>
</div>
<div>
{t("hosts.macos")}{" "}
<code className="bg-muted px-1 rounded inline">
brew install hudochenkov/sshpass/sshpass
</code>
</div>
<div> {t("hosts.windows")}</div>
</div>
</AlertDescription>
</Alert>
<Alert className="mt-4">
<AlertDescription>
<strong>{t("hosts.sshServerConfigRequired")}</strong>
<div>{t("hosts.sshServerConfigDesc")}</div>
<div>
{" "}
<code className="bg-muted px-1 rounded inline">
GatewayPorts yes
</code>{" "}
{t("hosts.gatewayPortsYes")}
</div>
<div>
{" "}
<code className="bg-muted px-1 rounded inline">
AllowTcpForwarding yes
</code>{" "}
{t("hosts.allowTcpForwardingYes")}
</div>
<div>
{" "}
<code className="bg-muted px-1 rounded inline">
PermitRootLogin yes
</code>{" "}
{t("hosts.permitRootLoginYes")}
</div>
<div className="mt-2">{t("hosts.editSshConfig")}</div>
</AlertDescription>
</Alert>
<div className="mt-3 flex justify-between">
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
onClick={() =>
window.open("https://docs.termix.site/tunnels", "_blank")
}
>
{t("common.documentation")}
</Button>
</div>
<FormField
control={control}
name="tunnelConnections"
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>{t("hosts.tunnelConnections")}</FormLabel>
<FormControl>
<div className="space-y-4">
{field.value.map((connection, index) => (
<div
key={index}
className="p-4 border rounded-lg bg-muted/50"
>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-bold">
{t("hosts.connection")} {index + 1}
</h4>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const newConnections = field.value.filter(
(_, i) => i !== index,
);
field.onChange(newConnections);
}}
>
{t("hosts.remove")}
</Button>
</div>
<div className="grid grid-cols-12 gap-4">
<FormField
control={control}
name={`tunnelConnections.${index}.sourcePort`}
render={({ field: sourcePortField }) => (
<FormItem className="col-span-4">
<FormLabel>
{t("hosts.sourcePort")}
{t("hosts.sourcePortDesc")}
</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.defaultPort")}
{...sourcePortField}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name={`tunnelConnections.${index}.endpointPort`}
render={({ field: endpointPortField }) => (
<FormItem className="col-span-4">
<FormLabel>{t("hosts.endpointPort")}</FormLabel>
<FormControl>
<Input
placeholder={t(
"placeholders.defaultEndpointPort",
)}
{...endpointPortField}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name={`tunnelConnections.${index}.endpointHost`}
render={({ field: endpointHostField }) => (
<FormItem className="col-span-4 relative">
<FormLabel>
{t("hosts.endpointSshConfig")}
</FormLabel>
<FormControl>
<Input
ref={(el) => {
sshConfigInputRefs.current[index] = el;
}}
placeholder={t("placeholders.sshConfig")}
className="min-h-[40px]"
autoComplete="off"
value={endpointHostField.value}
onFocus={() =>
setSshConfigDropdownOpen((prev) => ({
...prev,
[index]: true,
}))
}
onChange={(e) => {
endpointHostField.onChange(e);
setSshConfigDropdownOpen((prev) => ({
...prev,
[index]: true,
}));
}}
onBlur={(e) => {
endpointHostField.onChange(
e.target.value.trim(),
);
endpointHostField.onBlur();
}}
/>
</FormControl>
{sshConfigDropdownOpen[index] &&
getFilteredSshConfigs(index).length > 0 && (
<div
ref={(el) => {
sshConfigDropdownRefs.current[index] =
el;
}}
className="absolute top-full left-0 z-50 mt-1 w-full bg-canvas border border-input rounded-md shadow-lg max-h-40 overflow-y-auto thin-scrollbar p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{getFilteredSshConfigs(index).map(
(config) => (
<Button
key={config}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded px-2 py-1.5 hover:bg-surface-hover focus:bg-surface-hover focus:outline-none"
onClick={() =>
handleSshConfigClick(
config,
index,
)
}
>
{config}
</Button>
),
)}
</div>
</div>
)}
</FormItem>
)}
/>
</div>
<p className="text-sm text-muted-foreground mt-2">
{t("hosts.tunnelForwardDescription", {
sourcePort:
watch(`tunnelConnections.${index}.sourcePort`) ||
"22",
endpointPort:
watch(
`tunnelConnections.${index}.endpointPort`,
) || "224",
})}
</p>
<div className="grid grid-cols-12 gap-4 mt-4">
<FormField
control={control}
name={`tunnelConnections.${index}.maxRetries`}
render={({ field: maxRetriesField }) => (
<FormItem className="col-span-4">
<FormLabel>{t("hosts.maxRetries")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.maxRetries")}
{...maxRetriesField}
/>
</FormControl>
<FormDescription>
{t("hosts.maxRetriesDescription")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={control}
name={`tunnelConnections.${index}.retryInterval`}
render={({ field: retryIntervalField }) => (
<FormItem className="col-span-4">
<FormLabel>
{t("hosts.retryInterval")}
</FormLabel>
<FormControl>
<Input
placeholder={t(
"placeholders.retryInterval",
)}
{...retryIntervalField}
/>
</FormControl>
<FormDescription>
{t("hosts.retryIntervalDescription")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={control}
name={`tunnelConnections.${index}.autoStart`}
render={({ field }) => (
<FormItem className="col-span-4">
<FormLabel>
{t("hosts.autoStartContainer")}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t("hosts.autoStartDesc")}
</FormDescription>
</FormItem>
)}
/>
</div>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() => {
field.onChange([
...field.value,
{
sourcePort: 22,
endpointPort: 224,
endpointHost: "",
maxRetries: 3,
retryInterval: 10,
autoStart: false,
},
]);
}}
>
{t("hosts.addConnection")}
</Button>
</div>
</FormControl>
</FormItem>
)}
/>
</>
)}
</div>
);
}

View File

@@ -1,157 +1,5 @@
import type {
Control,
UseFormWatch,
UseFormSetValue,
UseFormGetValues,
} from "react-hook-form";
import type { SSHHost, Credential } from "@/types";
import type { SSHHost } from "@/types";
/**
* Minimal props for simple tabs (Docker, File Manager)
*/
export interface MinimalTabProps<TFormData = any> {
control: Control<TFormData>;
t: (key: string, params?: any) => string;
}
/**
* Base props that all HostManager tabs receive
*/
export interface BaseHostTabProps<TFormData = any> {
// Form integration
control: Control<TFormData>;
watch: UseFormWatch<TFormData>;
setValue: UseFormSetValue<TFormData>;
getValues: UseFormGetValues<TFormData>;
// Shared state (read-only for tabs)
hosts: SSHHost[];
credentials: Credential[];
folders: string[];
snippets: Array<{ id: number; name: string; content: string }>;
// Theme context
editorTheme: any; // CodeMirror theme
// Translation
t: (key: string, params?: any) => string;
// Current editing context
editingHost?: SSHHost | null;
}
/**
* Props for tabs that need tab state management
*/
export interface TabWithStateProps<
TFormData = any,
> extends BaseHostTabProps<TFormData> {
// Tab-specific state setters (for nested tabs like auth)
activeAuthTab?: "password" | "key" | "credential" | "none";
onAuthTabChange?: (tab: "password" | "key" | "credential" | "none") => void;
}
/**
* Props for tabs that need conditional rendering based on form state
*/
export interface ConditionalTabProps<
TFormData = any,
> extends BaseHostTabProps<TFormData> {
// For tabs that show/hide content based on form.watch()
isNewHost: boolean;
}
/**
* Props for the Docker tab
*/
export interface HostDockerTabProps extends MinimalTabProps {}
/**
* Props for the File Manager tab
*/
export interface HostFileManagerTabProps {
control: Control<any>;
watch: UseFormWatch<any>;
t: (key: string, params?: any) => string;
}
/**
* Props for the Tunnel tab
*/
export interface HostTunnelTabProps {
control: Control<any>;
watch: UseFormWatch<any>;
setValue: UseFormSetValue<any>;
getValues: UseFormGetValues<any>;
sshConfigurations: string[];
editingHost?: SSHHost | null;
t: (key: string, params?: any) => string;
}
/**
* Props for the Statistics tab
*/
export interface HostStatisticsTabProps {
control: Control<any>;
watch: UseFormWatch<any>;
setValue: UseFormSetValue<any>;
statusIntervalUnit: "seconds" | "minutes";
setStatusIntervalUnit: (unit: "seconds" | "minutes") => void;
metricsIntervalUnit: "seconds" | "minutes";
setMetricsIntervalUnit: (unit: "seconds" | "minutes") => void;
t: (key: string, params?: any) => string;
}
/**
* Props for the Terminal tab
*/
export interface HostTerminalTabProps {
control: Control<any>;
watch: UseFormWatch<any>;
setValue: UseFormSetValue<any>;
snippets: Array<{ id: number; name: string; content: string }>;
editorTheme: any;
t: (key: string, params?: any) => string;
}
/**
* Props for the General tab
*/
export interface HostGeneralTabProps extends BaseHostTabProps {
// Auth state
authTab: "password" | "key" | "credential" | "none";
setAuthTab: (tab: "password" | "key" | "credential" | "none") => void;
keyInputMethod: "upload" | "paste";
setKeyInputMethod: (method: "upload" | "paste") => void;
// Proxy mode state
proxyMode: "single" | "chain";
setProxyMode: (mode: "single" | "chain") => void;
// Ref for IP input focus
ipInputRef?: React.RefObject<HTMLInputElement>;
}
/**
* Props for the Authentication Section (nested in General tab)
*/
export interface HostAuthenticationSectionProps {
control: Control<any>;
watch: UseFormWatch<any>;
setValue: UseFormSetValue<any>;
credentials: Credential[];
authTab: "password" | "key" | "credential" | "none";
setAuthTab: (tab: "password" | "key" | "credential" | "none") => void;
keyInputMethod: "upload" | "paste";
setKeyInputMethod: (method: "upload" | "paste") => void;
editorTheme: any;
editingHost?: SSHHost | null;
t: (key: string, params?: any) => string;
}
/**
* Props for JumpHostItem component
*/
export interface JumpHostItemProps {
jumpHost: { hostId: number };
index: number;
@@ -162,9 +10,6 @@ export interface JumpHostItemProps {
t: (key: string) => string;
}
/**
* Props for QuickActionItem component
*/
export interface QuickActionItemProps {
quickAction: { name: string; snippetId: number };
index: number;