fix: properly split tabs, still need to fix up the host manager
This commit is contained in:
@@ -975,7 +975,6 @@
|
|||||||
"monitoringDisabledBadge": "Monitoring Off",
|
"monitoringDisabledBadge": "Monitoring Off",
|
||||||
"statusMonitoring": "Status",
|
"statusMonitoring": "Status",
|
||||||
"metricsMonitoring": "Metrics",
|
"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",
|
"terminalCustomization": "Terminal Customization",
|
||||||
"appearance": "Appearance",
|
"appearance": "Appearance",
|
||||||
"behavior": "Behavior",
|
"behavior": "Behavior",
|
||||||
|
|||||||
@@ -498,6 +498,8 @@ export interface HostManagerProps {
|
|||||||
_updateTimestamp?: number;
|
_updateTimestamp?: number;
|
||||||
rightSidebarOpen?: boolean;
|
rightSidebarOpen?: boolean;
|
||||||
rightSidebarWidth?: number;
|
rightSidebarWidth?: number;
|
||||||
|
currentTabId?: number;
|
||||||
|
updateTab?: (tabId: number, updates: Partial<Omit<Tab, "id">>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSHManagerHostEditorProps {
|
export interface SSHManagerHostEditorProps {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function AppContent() {
|
|||||||
const [transitionPhase, setTransitionPhase] = useState<
|
const [transitionPhase, setTransitionPhase] = useState<
|
||||||
"idle" | "fadeOut" | "fadeIn"
|
"idle" | "fadeOut" | "fadeIn"
|
||||||
>("idle");
|
>("idle");
|
||||||
const { currentTab, tabs } = useTabs();
|
const { currentTab, tabs, updateTab } = useTabs();
|
||||||
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
|
const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
|
||||||
@@ -280,6 +280,8 @@ function AppContent() {
|
|||||||
_updateTimestamp={currentTabData?._updateTimestamp}
|
_updateTimestamp={currentTabData?._updateTimestamp}
|
||||||
rightSidebarOpen={rightSidebarOpen}
|
rightSidebarOpen={rightSidebarOpen}
|
||||||
rightSidebarWidth={rightSidebarWidth}
|
rightSidebarWidth={rightSidebarWidth}
|
||||||
|
currentTabId={currentTab}
|
||||||
|
updateTab={updateTab}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import {
|
import { Form } from "@/components/ui/form.tsx";
|
||||||
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 { ScrollArea } from "@/components/ui/scroll-area.tsx";
|
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
|
||||||
import { Separator } from "@/components/ui/separator.tsx";
|
import { Separator } from "@/components/ui/separator.tsx";
|
||||||
import {
|
import {
|
||||||
@@ -31,20 +22,18 @@ import {
|
|||||||
getCredentialDetails,
|
getCredentialDetails,
|
||||||
detectKeyType,
|
detectKeyType,
|
||||||
detectPublicKeyType,
|
detectPublicKeyType,
|
||||||
generatePublicKeyFromPrivate,
|
|
||||||
generateKeyPair,
|
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import CodeMirror from "@uiw/react-codemirror";
|
|
||||||
import { oneDark } from "@codemirror/theme-one-dark";
|
import { oneDark } from "@codemirror/theme-one-dark";
|
||||||
import { githubLight } from "@uiw/codemirror-theme-github";
|
import { githubLight } from "@uiw/codemirror-theme-github";
|
||||||
import { EditorView } from "@codemirror/view";
|
|
||||||
import { useTheme } from "@/components/theme-provider.tsx";
|
import { useTheme } from "@/components/theme-provider.tsx";
|
||||||
import type {
|
import type {
|
||||||
Credential,
|
Credential,
|
||||||
CredentialEditorProps,
|
CredentialEditorProps,
|
||||||
CredentialData,
|
CredentialData,
|
||||||
} from "../../../../../types";
|
} from "../../../../../types";
|
||||||
|
import { CredentialGeneralTab } from "./tabs/CredentialGeneralTab";
|
||||||
|
import { CredentialAuthenticationTab } from "./tabs/CredentialAuthenticationTab";
|
||||||
|
|
||||||
export function CredentialEditor({
|
export function CredentialEditor({
|
||||||
editingCredential,
|
editingCredential,
|
||||||
@@ -503,694 +492,39 @@ export function CredentialEditor({
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="general" className="pt-2">
|
<TabsContent value="general" className="pt-2">
|
||||||
<FormLabel className="mb-2 font-bold">
|
<CredentialGeneralTab
|
||||||
{t("credentials.basicInformation")}
|
form={form}
|
||||||
</FormLabel>
|
folders={folders}
|
||||||
<div className="grid grid-cols-12 gap-3">
|
tagInput={tagInput}
|
||||||
<FormField
|
setTagInput={setTagInput}
|
||||||
control={form.control}
|
folderDropdownOpen={folderDropdownOpen}
|
||||||
name="name"
|
setFolderDropdownOpen={setFolderDropdownOpen}
|
||||||
render={({ field }) => (
|
folderInputRef={folderInputRef}
|
||||||
<FormItem className="col-span-6">
|
folderDropdownRef={folderDropdownRef}
|
||||||
<FormLabel>{t("credentials.credentialName")}</FormLabel>
|
filteredFolders={filteredFolders}
|
||||||
<FormControl>
|
handleFolderClick={handleFolderClick}
|
||||||
<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>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="authentication">
|
<TabsContent value="authentication">
|
||||||
<FormLabel className="mb-2 font-bold">
|
<CredentialAuthenticationTab
|
||||||
{t("credentials.authentication")}
|
form={form}
|
||||||
</FormLabel>
|
authTab={authTab}
|
||||||
<Tabs
|
setAuthTab={setAuthTab}
|
||||||
value={authTab}
|
detectedKeyType={detectedKeyType}
|
||||||
onValueChange={(value) => {
|
setDetectedKeyType={setDetectedKeyType}
|
||||||
const newAuthType = value as "password" | "key";
|
keyDetectionLoading={keyDetectionLoading}
|
||||||
setAuthTab(newAuthType);
|
setKeyDetectionLoading={setKeyDetectionLoading}
|
||||||
form.setValue("authType", newAuthType);
|
detectedPublicKeyType={detectedPublicKeyType}
|
||||||
|
setDetectedPublicKeyType={setDetectedPublicKeyType}
|
||||||
form.setValue("password", "");
|
publicKeyDetectionLoading={publicKeyDetectionLoading}
|
||||||
form.setValue("key", null);
|
setPublicKeyDetectionLoading={setPublicKeyDetectionLoading}
|
||||||
form.setValue("keyPassword", "");
|
keyDetectionTimeoutRef={keyDetectionTimeoutRef}
|
||||||
form.setValue("keyType", "auto");
|
publicKeyDetectionTimeoutRef={publicKeyDetectionTimeoutRef}
|
||||||
}}
|
editorTheme={editorTheme}
|
||||||
className="flex-1 flex flex-col h-full min-h-0"
|
debouncedKeyDetection={debouncedKeyDetection}
|
||||||
>
|
debouncedPublicKeyDetection={debouncedPublicKeyDetection}
|
||||||
<TabsList className="bg-button border border-edge-medium">
|
getFriendlyKeyTypeName={getFriendlyKeyTypeName}
|
||||||
<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>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -1,73 +1,44 @@
|
|||||||
import React from "react";
|
|
||||||
import { Controller } from "react-hook-form";
|
|
||||||
import {
|
import {
|
||||||
FormControl,
|
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
} from "@/components/ui/form.tsx";
|
} from "@/components/ui/form.tsx";
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
|
||||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||||
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
TabsList,
|
TabsList,
|
||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
} from "@/components/ui/tabs.tsx";
|
} from "@/components/ui/tabs.tsx";
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
import CodeMirror from "@uiw/react-codemirror";
|
import CodeMirror from "@uiw/react-codemirror";
|
||||||
import { EditorView } from "@codemirror/view";
|
import { EditorView } from "@codemirror/view";
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Control, UseFormWatch, UseFormSetValue } from "react-hook-form";
|
import {
|
||||||
|
|
||||||
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,
|
|
||||||
generateKeyPair,
|
generateKeyPair,
|
||||||
generatePublicKeyFromPrivate,
|
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,
|
getFriendlyKeyTypeName,
|
||||||
t,
|
|
||||||
}: CredentialAuthenticationTabProps) {
|
}: CredentialAuthenticationTabProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormLabel className="mb-2 font-bold">
|
<FormLabel className="mb-2 font-bold">
|
||||||
@@ -78,12 +49,12 @@ export function CredentialAuthenticationTab({
|
|||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
const newAuthType = value as "password" | "key";
|
const newAuthType = value as "password" | "key";
|
||||||
setAuthTab(newAuthType);
|
setAuthTab(newAuthType);
|
||||||
setValue("authType", newAuthType);
|
form.setValue("authType", newAuthType);
|
||||||
|
|
||||||
setValue("password", "");
|
form.setValue("password", "");
|
||||||
setValue("key", null);
|
form.setValue("key", null);
|
||||||
setValue("keyPassword", "");
|
form.setValue("keyPassword", "");
|
||||||
setValue("keyType", "auto");
|
form.setValue("keyType", "auto");
|
||||||
}}
|
}}
|
||||||
className="flex-1 flex flex-col h-full min-h-0"
|
className="flex-1 flex flex-col h-full min-h-0"
|
||||||
>
|
>
|
||||||
@@ -103,7 +74,7 @@ export function CredentialAuthenticationTab({
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="password">
|
<TabsContent value="password">
|
||||||
<FormField
|
<FormField
|
||||||
control={control}
|
control={form.control}
|
||||||
name="password"
|
name="password"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
@@ -138,7 +109,7 @@ export function CredentialAuthenticationTab({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
const currentKeyPassword = watch("keyPassword");
|
const currentKeyPassword = form.watch("keyPassword");
|
||||||
const result = await generateKeyPair(
|
const result = await generateKeyPair(
|
||||||
"ssh-ed25519",
|
"ssh-ed25519",
|
||||||
undefined,
|
undefined,
|
||||||
@@ -146,8 +117,8 @@ export function CredentialAuthenticationTab({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setValue("key", result.privateKey);
|
form.setValue("key", result.privateKey);
|
||||||
setValue("publicKey", result.publicKey);
|
form.setValue("publicKey", result.publicKey);
|
||||||
debouncedKeyDetection(
|
debouncedKeyDetection(
|
||||||
result.privateKey,
|
result.privateKey,
|
||||||
currentKeyPassword,
|
currentKeyPassword,
|
||||||
@@ -181,7 +152,7 @@ export function CredentialAuthenticationTab({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
const currentKeyPassword = watch("keyPassword");
|
const currentKeyPassword = form.watch("keyPassword");
|
||||||
const result = await generateKeyPair(
|
const result = await generateKeyPair(
|
||||||
"ecdsa-sha2-nistp256",
|
"ecdsa-sha2-nistp256",
|
||||||
undefined,
|
undefined,
|
||||||
@@ -189,8 +160,8 @@ export function CredentialAuthenticationTab({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setValue("key", result.privateKey);
|
form.setValue("key", result.privateKey);
|
||||||
setValue("publicKey", result.publicKey);
|
form.setValue("publicKey", result.publicKey);
|
||||||
debouncedKeyDetection(
|
debouncedKeyDetection(
|
||||||
result.privateKey,
|
result.privateKey,
|
||||||
currentKeyPassword,
|
currentKeyPassword,
|
||||||
@@ -224,7 +195,7 @@ export function CredentialAuthenticationTab({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
const currentKeyPassword = watch("keyPassword");
|
const currentKeyPassword = form.watch("keyPassword");
|
||||||
const result = await generateKeyPair(
|
const result = await generateKeyPair(
|
||||||
"ssh-rsa",
|
"ssh-rsa",
|
||||||
2048,
|
2048,
|
||||||
@@ -232,8 +203,8 @@ export function CredentialAuthenticationTab({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setValue("key", result.privateKey);
|
form.setValue("key", result.privateKey);
|
||||||
setValue("publicKey", result.publicKey);
|
form.setValue("publicKey", result.publicKey);
|
||||||
debouncedKeyDetection(
|
debouncedKeyDetection(
|
||||||
result.privateKey,
|
result.privateKey,
|
||||||
currentKeyPassword,
|
currentKeyPassword,
|
||||||
@@ -262,7 +233,7 @@ export function CredentialAuthenticationTab({
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 items-start">
|
<div className="grid grid-cols-2 gap-3 items-start">
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={form.control}
|
||||||
name="key"
|
name="key"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="mb-3 flex flex-col">
|
<FormItem className="mb-3 flex flex-col">
|
||||||
@@ -283,7 +254,7 @@ export function CredentialAuthenticationTab({
|
|||||||
field.onChange(fileContent);
|
field.onChange(fileContent);
|
||||||
debouncedKeyDetection(
|
debouncedKeyDetection(
|
||||||
fileContent,
|
fileContent,
|
||||||
watch("keyPassword"),
|
form.watch("keyPassword"),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -313,7 +284,10 @@ export function CredentialAuthenticationTab({
|
|||||||
}
|
}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
debouncedKeyDetection(value, watch("keyPassword"));
|
debouncedKeyDetection(
|
||||||
|
value,
|
||||||
|
form.watch("keyPassword"),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
placeholder={t("placeholders.pastePrivateKey")}
|
placeholder={t("placeholders.pastePrivateKey")}
|
||||||
theme={editorTheme}
|
theme={editorTheme}
|
||||||
@@ -366,7 +340,7 @@ export function CredentialAuthenticationTab({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={form.control}
|
||||||
name="publicKey"
|
name="publicKey"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="mb-3 flex flex-col">
|
<FormItem className="mb-3 flex flex-col">
|
||||||
@@ -411,7 +385,7 @@ export function CredentialAuthenticationTab({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const privateKey = watch("key");
|
const privateKey = form.watch("key");
|
||||||
if (
|
if (
|
||||||
!privateKey ||
|
!privateKey ||
|
||||||
typeof privateKey !== "string" ||
|
typeof privateKey !== "string" ||
|
||||||
@@ -424,7 +398,7 @@ export function CredentialAuthenticationTab({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const keyPassword = watch("keyPassword");
|
const keyPassword = form.watch("keyPassword");
|
||||||
const result = await generatePublicKeyFromPrivate(
|
const result = await generatePublicKeyFromPrivate(
|
||||||
privateKey,
|
privateKey,
|
||||||
keyPassword,
|
keyPassword,
|
||||||
@@ -517,7 +491,7 @@ export function CredentialAuthenticationTab({
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-8 gap-3 mt-3">
|
<div className="grid grid-cols-8 gap-3 mt-3">
|
||||||
<FormField
|
<FormField
|
||||||
control={control}
|
control={form.control}
|
||||||
name="keyPassword"
|
name="keyPassword"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="col-span-8">
|
<FormItem className="col-span-8">
|
||||||
|
|||||||
@@ -1,70 +1,28 @@
|
|||||||
import React, { useRef, useState, useEffect } from "react";
|
|
||||||
import {
|
import {
|
||||||
FormControl,
|
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
} from "@/components/ui/form.tsx";
|
} from "@/components/ui/form.tsx";
|
||||||
import { Input } from "@/components/ui/input.tsx";
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import type { Control, UseFormWatch } from "react-hook-form";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
interface CredentialGeneralTabProps {
|
import type { CredentialGeneralTabProps } from "./shared/tab-types";
|
||||||
control: Control<any>;
|
|
||||||
watch: UseFormWatch<any>;
|
|
||||||
folders: string[];
|
|
||||||
t: (key: string, params?: any) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CredentialGeneralTab({
|
export function CredentialGeneralTab({
|
||||||
control,
|
form,
|
||||||
watch,
|
|
||||||
folders,
|
folders,
|
||||||
t,
|
tagInput,
|
||||||
|
setTagInput,
|
||||||
|
folderDropdownOpen,
|
||||||
|
setFolderDropdownOpen,
|
||||||
|
folderInputRef,
|
||||||
|
folderDropdownRef,
|
||||||
|
filteredFolders,
|
||||||
|
handleFolderClick,
|
||||||
}: CredentialGeneralTabProps) {
|
}: CredentialGeneralTabProps) {
|
||||||
const [tagInput, setTagInput] = useState("");
|
const { t } = useTranslation();
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -73,7 +31,7 @@ export function CredentialGeneralTab({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<div className="grid grid-cols-12 gap-3">
|
<div className="grid grid-cols-12 gap-3">
|
||||||
<FormField
|
<FormField
|
||||||
control={control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="col-span-6">
|
<FormItem className="col-span-6">
|
||||||
@@ -89,7 +47,7 @@ export function CredentialGeneralTab({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={control}
|
control={form.control}
|
||||||
name="username"
|
name="username"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="col-span-6">
|
<FormItem className="col-span-6">
|
||||||
@@ -106,7 +64,7 @@ export function CredentialGeneralTab({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<div className="grid grid-cols-26 gap-3">
|
<div className="grid grid-cols-26 gap-3">
|
||||||
<FormField
|
<FormField
|
||||||
control={control}
|
control={form.control}
|
||||||
name="description"
|
name="description"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="col-span-10">
|
<FormItem className="col-span-10">
|
||||||
@@ -119,7 +77,7 @@ export function CredentialGeneralTab({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={control}
|
control={form.control}
|
||||||
name="folder"
|
name="folder"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="col-span-10 relative">
|
<FormItem className="col-span-10 relative">
|
||||||
@@ -151,9 +109,7 @@ export function CredentialGeneralTab({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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"
|
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={() =>
|
onClick={() => handleFolderClick(folder)}
|
||||||
handleFolderClick(folder, field.onChange)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{folder}
|
{folder}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -166,7 +122,7 @@ export function CredentialGeneralTab({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={control}
|
control={form.control}
|
||||||
name="tags"
|
name="tags"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="col-span-10 overflow-visible">
|
<FormItem className="col-span-10 overflow-visible">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ export function HostManager({
|
|||||||
_updateTimestamp,
|
_updateTimestamp,
|
||||||
rightSidebarOpen = false,
|
rightSidebarOpen = false,
|
||||||
rightSidebarWidth = 400,
|
rightSidebarWidth = 400,
|
||||||
|
currentTabId,
|
||||||
|
updateTab,
|
||||||
}: HostManagerProps): React.ReactElement {
|
}: HostManagerProps): React.ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState(initialTab);
|
const [activeTab, setActiveTab] = useState(initialTab);
|
||||||
@@ -75,6 +77,11 @@ export function HostManager({
|
|||||||
setEditingHost(host);
|
setEditingHost(host);
|
||||||
setActiveTab("add_host");
|
setActiveTab("add_host");
|
||||||
lastProcessedHostIdRef.current = host.id;
|
lastProcessedHostIdRef.current = host.id;
|
||||||
|
|
||||||
|
// Persist to tab context
|
||||||
|
if (updateTab && currentTabId !== undefined) {
|
||||||
|
updateTab(currentTabId, { initialTab: "add_host" });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFormSubmit = () => {
|
const handleFormSubmit = () => {
|
||||||
@@ -93,6 +100,11 @@ export function HostManager({
|
|||||||
}) => {
|
}) => {
|
||||||
setEditingCredential(credential);
|
setEditingCredential(credential);
|
||||||
setActiveTab("add_credential");
|
setActiveTab("add_credential");
|
||||||
|
|
||||||
|
// Persist to tab context
|
||||||
|
if (updateTab && currentTabId !== undefined) {
|
||||||
|
updateTab(currentTabId, { initialTab: "add_credential" });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCredentialFormSubmit = () => {
|
const handleCredentialFormSubmit = () => {
|
||||||
@@ -108,6 +120,11 @@ export function HostManager({
|
|||||||
setEditingCredential(null);
|
setEditingCredential(null);
|
||||||
}
|
}
|
||||||
setActiveTab(value);
|
setActiveTab(value);
|
||||||
|
|
||||||
|
// Persist to tab context
|
||||||
|
if (updateTab && currentTabId !== undefined) {
|
||||||
|
updateTab(currentTabId, { initialTab: value });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ import {
|
|||||||
} from "@/components/ui/form.tsx";
|
} from "@/components/ui/form.tsx";
|
||||||
import { Input } from "@/components/ui/input.tsx";
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
import { PasswordInput } from "@/components/ui/password-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 { Textarea } from "@/components/ui/textarea.tsx";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
|
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
|
||||||
import { Separator } from "@/components/ui/separator.tsx";
|
import { Separator } from "@/components/ui/separator.tsx";
|
||||||
@@ -25,8 +34,9 @@ import {
|
|||||||
} from "@/components/ui/tabs.tsx";
|
} from "@/components/ui/tabs.tsx";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { Switch } from "@/components/ui/switch.tsx";
|
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 { toast } from "sonner";
|
||||||
|
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||||
import {
|
import {
|
||||||
createSSHHost,
|
createSSHHost,
|
||||||
getCredentials,
|
getCredentials,
|
||||||
@@ -35,10 +45,18 @@ import {
|
|||||||
enableAutoStart,
|
enableAutoStart,
|
||||||
disableAutoStart,
|
disableAutoStart,
|
||||||
getSnippets,
|
getSnippets,
|
||||||
|
getRoles,
|
||||||
|
getUserList,
|
||||||
|
getUserInfo,
|
||||||
|
shareHost,
|
||||||
|
getHostAccess,
|
||||||
|
revokeHostAccess,
|
||||||
|
getSSHHostById,
|
||||||
|
type Role,
|
||||||
|
type AccessRecord,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { CredentialSelector } from "@/ui/desktop/apps/host-manager/credentials/CredentialSelector.tsx";
|
import { CredentialSelector } from "@/ui/desktop/apps/host-manager/credentials/CredentialSelector.tsx";
|
||||||
import { HostSharingTab } from "./tabs/HostSharingTab.tsx";
|
|
||||||
import CodeMirror from "@uiw/react-codemirror";
|
import CodeMirror from "@uiw/react-codemirror";
|
||||||
import { oneDark } from "@codemirror/theme-one-dark";
|
import { oneDark } from "@codemirror/theme-one-dark";
|
||||||
import { githubLight } from "@uiw/codemirror-theme-github";
|
import { githubLight } from "@uiw/codemirror-theme-github";
|
||||||
@@ -91,7 +109,25 @@ import {
|
|||||||
} from "@/constants/terminal-themes.ts";
|
} from "@/constants/terminal-themes.ts";
|
||||||
import { TerminalPreview } from "@/ui/desktop/apps/features/terminal/TerminalPreview.tsx";
|
import { TerminalPreview } from "@/ui/desktop/apps/features/terminal/TerminalPreview.tsx";
|
||||||
import type { TerminalConfig, SSHHost, Credential } from "@/types";
|
import type { TerminalConfig, SSHHost, Credential } from "@/types";
|
||||||
import { Plus, X, Check, ChevronsUpDown, Save } from "lucide-react";
|
import {
|
||||||
|
Plus,
|
||||||
|
X,
|
||||||
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Save,
|
||||||
|
AlertCircle,
|
||||||
|
Trash2,
|
||||||
|
Users,
|
||||||
|
Shield,
|
||||||
|
Clock,
|
||||||
|
UserCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface JumpHostItemProps {
|
interface JumpHostItemProps {
|
||||||
jumpHost: { hostId: number };
|
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 {
|
interface SSHManagerHostEditorProps {
|
||||||
editingHost?: SSHHost | null;
|
editingHost?: SSHHost | null;
|
||||||
onFormSubmit?: (updatedHost?: SSHHost) => void;
|
onFormSubmit?: (updatedHost?: SSHHost) => void;
|
||||||
@@ -2322,7 +2855,28 @@ export function HostManagerEditor({
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
</TabsContent>
|
</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
|
<Accordion
|
||||||
type="multiple"
|
type="multiple"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -2333,6 +2887,124 @@ export function HostManagerEditor({
|
|||||||
{t("hosts.appearance")}
|
{t("hosts.appearance")}
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="space-y-4 pt-4">
|
<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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="terminalConfig.letterSpacing"
|
name="terminalConfig.letterSpacing"
|
||||||
@@ -3772,7 +4444,7 @@ export function HostManagerEditor({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="sharing" className="space-y-6">
|
<TabsContent value="sharing" className="space-y-6">
|
||||||
<HostSharingTab
|
<SharingTabContent
|
||||||
hostId={editingHost?.id}
|
hostId={editingHost?.id}
|
||||||
isNewHost={!editingHost?.id}
|
isNewHost={!editingHost?.id}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,157 +1,5 @@
|
|||||||
import type {
|
import type { SSHHost } from "@/types";
|
||||||
Control,
|
|
||||||
UseFormWatch,
|
|
||||||
UseFormSetValue,
|
|
||||||
UseFormGetValues,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import type { SSHHost, Credential } 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 {
|
export interface JumpHostItemProps {
|
||||||
jumpHost: { hostId: number };
|
jumpHost: { hostId: number };
|
||||||
index: number;
|
index: number;
|
||||||
@@ -162,9 +10,6 @@ export interface JumpHostItemProps {
|
|||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for QuickActionItem component
|
|
||||||
*/
|
|
||||||
export interface QuickActionItemProps {
|
export interface QuickActionItemProps {
|
||||||
quickAction: { name: string; snippetId: number };
|
quickAction: { name: string; snippetId: number };
|
||||||
index: number;
|
index: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user