mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
331 lines
13 KiB
TypeScript
331 lines
13 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Eye, EyeOff, Terminal } from "lucide-react";
|
|
import { Input } from "@/components/input";
|
|
import type { Host } from "@/types/ui-types";
|
|
import { getCredentials } from "@/api/credentials-api";
|
|
import { mapCredentials } from "./HostManagerData";
|
|
import { createQuickConnectHost } from "./quick-connect-host";
|
|
import { useHostProtocols } from "./host-protocols";
|
|
import { Select2 } from "@/components/select2";
|
|
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
|
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
|
import { useSshAuthEditors } from "@/plugin-host/auth-registry";
|
|
import { useHostActions } from "./host-contributions";
|
|
|
|
// Core types Quick Connect draws its own fields for.
|
|
const INLINE_AUTH_TYPES = new Set(["password", "key", "credential"]);
|
|
|
|
interface QuickConnectPanelProps {
|
|
onConnect: (host: Host, type: string) => void;
|
|
}
|
|
|
|
export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
|
const { t } = useTranslation();
|
|
const [host, setHost] = useState("");
|
|
// "ssh", or the id of a plugin protocol offered in Quick Connect.
|
|
const [protocol, setProtocol] = useState("ssh");
|
|
const [port, setPort] = useState("22");
|
|
const [domain, setDomain] = useState("");
|
|
const [username, setUsername] = useState("root");
|
|
const [authType, setAuthType] = useState("password");
|
|
const [authFields, setAuthFields] = useState<Record<string, unknown>>({});
|
|
const [password, setPassword] = useState("");
|
|
const [privateKey, setPrivateKey] = useState("");
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [credentialId, setCredentialId] = useState("");
|
|
const [credentials, setCredentials] = useState<
|
|
{ id: string; name: string; username: string }[]
|
|
>([]);
|
|
|
|
useEffect(() => {
|
|
getCredentials()
|
|
.then((res) => setCredentials(mapCredentials(res)))
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
// Every registered SSH auth type that works for a host that is never saved.
|
|
const { providers } = useSshAuthProviders();
|
|
const authOptions = providers.filter(
|
|
(option) => option.available && option.quickConnect,
|
|
);
|
|
const authEditor = useSshAuthEditors().find(
|
|
(editor) => editor.id === authType,
|
|
);
|
|
const AuthEditor = INLINE_AUTH_TYPES.has(authType)
|
|
? undefined
|
|
: authEditor?.component;
|
|
const quickActions = useHostActions().filter(
|
|
(action) => action.quickConnect && action.tabType,
|
|
);
|
|
|
|
const pluginProtocols = useHostProtocols().filter(
|
|
(entry) => entry.quickConnect,
|
|
);
|
|
const selected = pluginProtocols.find((entry) => entry.id === protocol);
|
|
const isDesktop = !!selected;
|
|
const defaultPort = (id: string) =>
|
|
String(pluginProtocols.find((entry) => entry.id === id)?.defaultPort ?? 22);
|
|
|
|
const switchProtocol = (next: string) => {
|
|
// Keep a port the user typed; only swap the protocol default.
|
|
if (port === defaultPort(protocol)) setPort(defaultPort(next));
|
|
setProtocol(next);
|
|
};
|
|
|
|
// The tab comes from whichever plugin connects hosts of this protocol.
|
|
const connect = (type?: string) => {
|
|
if (!host) return;
|
|
if (!isDesktop && !username) return;
|
|
const hostConfig = createQuickConnectHost({
|
|
ip: host,
|
|
port: parseInt(port) || parseInt(defaultPort(protocol)),
|
|
username,
|
|
authType: isDesktop ? "password" : authType,
|
|
password,
|
|
key: privateKey,
|
|
credentialId,
|
|
protocol: selected,
|
|
domain: domain || undefined,
|
|
authFields: INLINE_AUTH_TYPES.has(authType) ? undefined : authFields,
|
|
});
|
|
const target = type ?? resolveHostTabType(hostConfig);
|
|
if (target) onConnect(hostConfig, target);
|
|
};
|
|
|
|
const connectDefault = () => connect();
|
|
|
|
return (
|
|
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
|
<div className="flex flex-col gap-3 p-3">
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
{t("newUi.sidebar.quickConnect.protocolLabel")}
|
|
</label>
|
|
<div className="flex gap-1">
|
|
{["ssh", ...pluginProtocols.map((entry) => entry.id)].map(
|
|
(type) => (
|
|
<button
|
|
key={type}
|
|
onClick={() => switchProtocol(type)}
|
|
className={`flex-1 py-1 text-[10px] font-semibold border transition-colors uppercase ${
|
|
protocol === type
|
|
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
|
: "border-border text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
{type}
|
|
</button>
|
|
),
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
{t("newUi.sidebar.quickConnect.hostLabel")}
|
|
</label>
|
|
<Input
|
|
placeholder={t("newUi.sidebar.quickConnect.hostPlaceholder")}
|
|
value={host}
|
|
onChange={(e) => setHost(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") connectDefault();
|
|
}}
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
{t("newUi.sidebar.quickConnect.portLabel")}
|
|
</label>
|
|
<Input
|
|
placeholder={t("newUi.sidebar.quickConnect.portPlaceholder")}
|
|
value={port}
|
|
onChange={(e) => setPort(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") connectDefault();
|
|
}}
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
{t("newUi.sidebar.quickConnect.usernameLabel")}
|
|
</label>
|
|
<Input
|
|
placeholder={t("newUi.sidebar.quickConnect.usernamePlaceholder")}
|
|
value={username}
|
|
onFocus={() => {
|
|
if (username === "root") setUsername("");
|
|
}}
|
|
onBlur={() => {
|
|
if (username === "") setUsername("root");
|
|
}}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") connectDefault();
|
|
}}
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
{!isDesktop && (
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
{t("newUi.sidebar.quickConnect.authLabel")}
|
|
</label>
|
|
<div className="flex flex-wrap gap-1">
|
|
{authOptions.map((option) => (
|
|
<button
|
|
key={option.type}
|
|
onClick={() => {
|
|
setAuthType(option.type);
|
|
setAuthFields({});
|
|
}}
|
|
className={`flex-1 py-1 px-1.5 text-[10px] font-semibold border transition-colors ${
|
|
authType === option.type
|
|
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
|
: "border-border text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
{t(option.editorTitleKey ?? option.labelKey)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{(isDesktop || authType === "password") && (
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
{t("newUi.sidebar.quickConnect.passwordLabel")}
|
|
</label>
|
|
<div className="relative">
|
|
<Input
|
|
type={showPassword ? "text" : "password"}
|
|
placeholder={t(
|
|
"newUi.sidebar.quickConnect.passwordPlaceholder",
|
|
)}
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") connectDefault();
|
|
}}
|
|
className="h-7 text-xs pr-8"
|
|
/>
|
|
<button
|
|
onClick={() => setShowPassword((o) => !o)}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
>
|
|
{showPassword ? (
|
|
<EyeOff className="size-3.5" />
|
|
) : (
|
|
<Eye className="size-3.5" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{selected?.quickConnect?.showDomain && (
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
{t("newUi.sidebar.quickConnect.domainLabel")}
|
|
</label>
|
|
<Input
|
|
placeholder={t("newUi.sidebar.quickConnect.domainPlaceholder")}
|
|
value={domain}
|
|
onChange={(e) => setDomain(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") connectDefault();
|
|
}}
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
)}
|
|
{!isDesktop && authType === "key" && (
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
{t("newUi.sidebar.quickConnect.privateKeyLabel")}
|
|
</label>
|
|
<textarea
|
|
placeholder={t(
|
|
"newUi.sidebar.quickConnect.privateKeyPlaceholder",
|
|
)}
|
|
value={privateKey}
|
|
onChange={(e) => setPrivateKey(e.target.value)}
|
|
className="w-full h-24 px-2.5 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
|
/>
|
|
</div>
|
|
)}
|
|
{!isDesktop && authType === "credential" && (
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
{t("newUi.sidebar.quickConnect.credentialLabel")}
|
|
</label>
|
|
<Select2
|
|
value={credentialId}
|
|
onChange={(e) => {
|
|
const newId = e.target.value;
|
|
setCredentialId(newId);
|
|
const cred = credentials.find((c) => c.id === newId);
|
|
if (cred?.username) setUsername(cred.username);
|
|
}}
|
|
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
>
|
|
<option value="">
|
|
{t("newUi.sidebar.quickConnect.credentialPlaceholder")}
|
|
</option>
|
|
{credentials.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.username ? `${c.name} (${c.username})` : c.name}
|
|
</option>
|
|
))}
|
|
</Select2>
|
|
</div>
|
|
)}
|
|
{!isDesktop && AuthEditor && (
|
|
<AuthEditor
|
|
form={authFields}
|
|
setField={(key, value) =>
|
|
setAuthFields((current) => ({ ...current, [key]: value }))
|
|
}
|
|
/>
|
|
)}
|
|
<div className="flex flex-col gap-1.5 pt-1">
|
|
{selected ? (
|
|
<button
|
|
onClick={() => connect()}
|
|
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
|
|
>
|
|
<selected.icon className="size-3.5" />
|
|
{t("newUi.sidebar.quickConnect.connectWith", {
|
|
protocol: t(selected.titleKey),
|
|
})}
|
|
</button>
|
|
) : (
|
|
<>
|
|
<button
|
|
onClick={() => connect()}
|
|
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
|
|
>
|
|
<Terminal className="size-3.5" />
|
|
{t("newUi.sidebar.quickConnect.connectToTerminal")}
|
|
</button>
|
|
{quickActions.map((action) => (
|
|
<button
|
|
key={action.id}
|
|
onClick={() => connect(action.tabType)}
|
|
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
|
|
>
|
|
<action.icon className="size-3.5" />
|
|
{t("newUi.sidebar.quickConnect.connectToAction", {
|
|
name: t(action.titleKey),
|
|
})}
|
|
</button>
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|