mirror of
https://github.com/YuzuZensai/Termix.git
synced 2026-11-12 15:16:45 +00:00
* Add Helm and GitOps deployment setup * fix: build better-sqlite3 from source in Docker (#1267) * fix: preserve runtime SSL settings (#1268) * fix: support forwarding from the memory SSH agent (#1269) * fix: support forwarding from the memory agent * style: format memory agent test * fix: prompt for encrypted SFTP key passphrases (#1270) * fix: prompt for SFTP key passphrases * style: format SSH key utility test * fix: include host context in automation notifications (#1271) * fix: include host context in automation notifications * style: format automation notification changes * fix: reserve sidebar height for host tags (#1272) * fix: keep host action rows stable at large font sizes (#1273) * fix: honor certificate setting during server probe (#1274) * fix: package standard Linux icon sizes (#1275) * fix: avoid duplicate Docker HTTPS listener (#1276) * Fix host status without metrics collection (#1277) * fix: allow eight-digit secure auth codes (#1263) Allow TOTP prompts to accept secure auth codes longer than six digits without blocking valid authentication attempts. Generated with Codebuff 🤖 Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> * Harden Helm deployment defaults * Update Helm workflow action * Exclude Helm templates from Prettier * Fix browser RDP file drops (#1279) * Fix Proxmox guest credential usernames (#1280) * Add WSL local terminal option (#1281) * refactor: split the transfer engine into focused modules (#1282) * refactor: extract SFTP promisify helpers into sftp-promisify module * refactor: extract transfer timing and rate stats into transfer-stats module * refactor: extract transfer error classes and recovery checks into transfer-errors module * refactor: extract host/path utility helpers into transfer-host-utils module * refactor: extract SFTP directory tree helpers into transfer-sftp-dir module * refactor: extract segment copy job builder into transfer-segment-copy module * refactor: extract file scan and sample helpers into transfer-scan module * refactor: move throttled progress helper into transfer-stats module * style: format transfer modules * perf: optimize tmux monitor aggregation (#1283) * fix: reserve credential tag row height (#1284) * feat: edit AI provider model settings (#1285) * fix: clarify click-to-expand host setting (#1286) * fix: allow portable imports on remote databases (#1287) * fix: allow HTTPS to share the configured port (#1288) * fix: resolve synced jump hosts on the server (#1289) * fix: make terminal clipboard shortcuts layout independent (#1290) * fix: use compatible fetch dispatcher for Tailscale (#1291) * fix: add OIDC environment recovery override (#1292) * fix: coalesce rapid mobile terminal input (#1293) * fix: coalesce rapid mobile terminal input * fix: support clean xterm patch installs * fix: resolve synced remote desktop host IDs (#1295) * feat: make the SFTP file manager path bar editable (#1294) Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * feat: add passkey sign in to the login screen * fix: remove rounded corners from the host list search bar * fix: stop image storage settings text wrapping to one word per line * fix: prevent malformed websocket messages from crashing the server * chore: increment version * fix: remove gaps between host rows in the sidebar list Keep sub-pixel row measurements and stop wiping the size cache on hover. * fix: Failed to connect through jump hosts (#1180) https://github.com/Termix-SSH/Support/issues/1180 * feat: Progress bar for file downloads in the file manager (#1158) https://github.com/Termix-SSH/Support/issues/1158 * feat: Allow setting Silent OIDC Login via ENV var (#1174) https://github.com/Termix-SSH/Support/issues/1174 * feat: `IdentityFile` to limit the number of attempts by agents (#1165) https://github.com/Termix-SSH/Support/issues/1165 * feat: Credentials clone (#1159) https://github.com/Termix-SSH/Support/issues/1159 * chore: update release notes * docs: move helm setup guide to the docs site * fix: type errors in FilteredAgent agent identity handling * fix: remove stale better-sqlite3 prebuilds so the source build is used * fix: actually build better-sqlite3 from source so arm64 docker images work * fix: credential edit pencil in host editor and add clone action to credential list * fix: clear editingHost so the credential pencil actually opens the editor * chore: run format and lint * fix: folder drag and drop upload failing in the file manager * chore: sync Crowdin translations for 2.7.1 --------- Co-authored-by: alex-ctms <alex-ctms@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Chetan Kumar <74929596+ckloop@users.noreply.github.com> Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> Co-authored-by: ZacharyZcR <payasonorahc@protonmail.com> Co-authored-by: dropafterfree <maxime.bonillo@gmail.com> Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com>
279 lines
9.1 KiB
TypeScript
279 lines
9.1 KiB
TypeScript
import React, { useState, useEffect, useRef } from "react";
|
|
import { Button } from "@/components/button.tsx";
|
|
import { Input } from "@/components/input.tsx";
|
|
import { Label } from "@/components/label.tsx";
|
|
import { Alert, AlertTitle, AlertDescription } from "@/components/alert.tsx";
|
|
import { Switch } from "@/components/switch.tsx";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ChevronDown, X } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
const SAVED_URLS_KEY = "termix_saved_server_urls";
|
|
const MAX_SAVED_URLS = 5;
|
|
|
|
function getSavedUrls(): string[] {
|
|
try {
|
|
const raw = localStorage.getItem(SAVED_URLS_KEY);
|
|
if (!raw) return [];
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function addSavedUrl(url: string) {
|
|
const urls = getSavedUrls().filter((u) => u !== url);
|
|
urls.unshift(url);
|
|
localStorage.setItem(
|
|
SAVED_URLS_KEY,
|
|
JSON.stringify(urls.slice(0, MAX_SAVED_URLS)),
|
|
);
|
|
}
|
|
|
|
function removeSavedUrl(url: string) {
|
|
const urls = getSavedUrls().filter((u) => u !== url);
|
|
localStorage.setItem(SAVED_URLS_KEY, JSON.stringify(urls));
|
|
}
|
|
|
|
interface RemoteSyncServerPickerProps {
|
|
onServerConfigured: (serverUrl: string) => void;
|
|
onCancel: () => void;
|
|
initialServerUrl?: string;
|
|
}
|
|
|
|
export function RemoteSyncServerPicker({
|
|
onServerConfigured,
|
|
onCancel,
|
|
initialServerUrl,
|
|
}: RemoteSyncServerPickerProps) {
|
|
const { t } = useTranslation();
|
|
const [serverUrl, setServerUrl] = useState(initialServerUrl ?? "");
|
|
const [allowInvalidCertificate, setAllowInvalidCertificate] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [savedUrls, setSavedUrls] = useState<string[]>([]);
|
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
setSavedUrls(getSavedUrls());
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
function handleClickOutside(e: MouseEvent) {
|
|
if (
|
|
dropdownRef.current &&
|
|
!dropdownRef.current.contains(e.target as Node)
|
|
) {
|
|
setDropdownOpen(false);
|
|
}
|
|
}
|
|
if (dropdownOpen) {
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
}
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, [dropdownOpen]);
|
|
|
|
const handleSaveConfig = async () => {
|
|
if (!serverUrl.trim()) {
|
|
setError(t("remoteSync.enterServerUrl"));
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const normalizedUrl = serverUrl.trim();
|
|
|
|
if (
|
|
!normalizedUrl.startsWith("http://") &&
|
|
!normalizedUrl.startsWith("https://")
|
|
) {
|
|
setError(t("remoteSync.mustIncludeProtocol"));
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
const testResult = (await window.electronAPI?.invoke?.(
|
|
"test-server-connection",
|
|
normalizedUrl,
|
|
normalizedUrl.startsWith("https://") && allowInvalidCertificate,
|
|
)) as { success?: boolean; error?: string; warning?: string } | undefined;
|
|
|
|
if (!testResult?.success) {
|
|
setError(testResult?.error || t("remoteSync.connectionTestFailed"));
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (testResult.warning) {
|
|
toast.warning(testResult.warning);
|
|
}
|
|
|
|
const result = await window.electronAPI?.invoke?.(
|
|
"save-remote-sync-config",
|
|
{
|
|
serverUrl: normalizedUrl,
|
|
allowInvalidCertificate:
|
|
normalizedUrl.startsWith("https://") && allowInvalidCertificate,
|
|
connectedAt: new Date().toISOString(),
|
|
},
|
|
);
|
|
|
|
if ((result as { success?: boolean })?.success) {
|
|
addSavedUrl(normalizedUrl);
|
|
setSavedUrls(getSavedUrls());
|
|
onServerConfigured(normalizedUrl);
|
|
} else {
|
|
setError(t("serverConfig.saveFailed"));
|
|
}
|
|
} catch {
|
|
setError(t("serverConfig.saveError"));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleUrlChange = (value: string) => {
|
|
setServerUrl(value);
|
|
setError(null);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5 p-6">
|
|
<div className="flex flex-col gap-1">
|
|
<p className="font-bold">{t("remoteSync.title")}</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("remoteSync.description")}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="remote-sync-server-url">
|
|
{t("remoteSync.serverUrl")}
|
|
</Label>
|
|
<div className="relative" ref={dropdownRef}>
|
|
<Input
|
|
id="remote-sync-server-url"
|
|
type="text"
|
|
placeholder="https://your-server.com"
|
|
value={serverUrl}
|
|
onChange={(e) => handleUrlChange(e.target.value)}
|
|
disabled={loading}
|
|
className={savedUrls.length > 0 ? "pr-9" : ""}
|
|
onFocus={() => {
|
|
if (savedUrls.length > 0) setDropdownOpen(true);
|
|
}}
|
|
/>
|
|
{savedUrls.length > 0 && (
|
|
<button
|
|
type="button"
|
|
tabIndex={-1}
|
|
onClick={() => setDropdownOpen((o) => !o)}
|
|
disabled={loading}
|
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
|
aria-label={t("remoteSync.savedServers")}
|
|
>
|
|
<ChevronDown className="size-4" />
|
|
</button>
|
|
)}
|
|
{dropdownOpen && savedUrls.length > 0 && (
|
|
<div className="absolute z-50 w-full top-full mt-1 border border-border bg-card shadow-md">
|
|
<p className="px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground border-b border-border">
|
|
{t("remoteSync.savedServers")}
|
|
</p>
|
|
{savedUrls.map((url) => (
|
|
<div
|
|
key={url}
|
|
className="flex items-center justify-between group hover:bg-muted transition-colors"
|
|
>
|
|
<button
|
|
type="button"
|
|
className="flex-1 text-left px-2.5 py-2 text-sm font-mono truncate"
|
|
onClick={() => {
|
|
handleUrlChange(url);
|
|
setDropdownOpen(false);
|
|
}}
|
|
>
|
|
{url}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="px-2 py-2 text-muted-foreground hover:text-destructive transition-colors shrink-0"
|
|
title={t("remoteSync.removeServer")}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
removeSavedUrl(url);
|
|
const updated = getSavedUrls();
|
|
setSavedUrls(updated);
|
|
if (updated.length === 0) setDropdownOpen(false);
|
|
}}
|
|
>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{serverUrl.trim().startsWith("https://") && (
|
|
<div className="flex items-start justify-between gap-3 border border-border bg-muted/20 p-3">
|
|
<div className="flex flex-col gap-1">
|
|
<Label htmlFor="remote-sync-allow-invalid-certificate">
|
|
{t("remoteSync.allowInvalidCertificate")}
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("remoteSync.allowInvalidCertificateDesc")}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="remote-sync-allow-invalid-certificate"
|
|
checked={allowInvalidCertificate}
|
|
onCheckedChange={setAllowInvalidCertificate}
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertTitle>{t("common.error")}</AlertTitle>
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="flex-1"
|
|
onClick={onCancel}
|
|
disabled={loading}
|
|
>
|
|
{t("remoteSync.cancelButton")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
className="flex-1 bg-accent-brand hover:bg-accent-brand/90 text-background font-bold"
|
|
onClick={handleSaveConfig}
|
|
disabled={loading || !serverUrl.trim()}
|
|
>
|
|
{loading ? (
|
|
<span className="flex items-center gap-2">
|
|
<div className="w-4 h-4 border-2 border-background border-t-transparent rounded-full animate-spin" />
|
|
{t("serverConfig.saving")}
|
|
</span>
|
|
) : (
|
|
t("remoteSync.continueButton")
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|