Files
dbx/apps/desktop/src/components/connection/TunnelProfileManager.vue
T

359 lines
17 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import PasswordInput from "@/components/ui/PasswordInput.vue";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Loader2, Plus, Trash2, FolderOpen } from "@lucide/vue";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useToast } from "@/composables/useToast";
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
import { createTunnelProfile, createTunnelProfileTestGuard, tunnelProfileSummary, type TunnelProfileType } from "@/lib/connection/tunnelProfiles";
import { applySshConfigHostAliasPrefill as prefillSshConfigHostAlias } from "@/lib/connection/sshConfigHosts";
import * as api from "@/lib/backend/api";
import type { SshConfigHostEntry, TunnelProfile } from "@/types/database";
import { translateBackendError } from "@/i18n/backend-errors";
const { t } = useI18n();
const { toast } = useToast();
const store = useTunnelProfileStore();
const draft = ref<TunnelProfile[]>([]);
const selectedId = ref<string | null>(null);
const isSaving = ref(false);
const hasInitializedDraft = ref(false);
const isTesting = ref(false);
const testResult = ref<{ ok: boolean; message: string } | null>(null);
const testGuard = createTunnelProfileTestGuard();
const sshConfigHosts = ref<SshConfigHostEntry[]>([]);
function cloneProfiles(profiles: TunnelProfile[]): TunnelProfile[] {
return JSON.parse(JSON.stringify(profiles)) as TunnelProfile[];
}
function resetDraft() {
draft.value = cloneProfiles(store.profiles);
if (!draft.value.some((profile) => profile.id === selectedId.value)) {
selectedId.value = draft.value[0]?.id || null;
}
}
const isDirty = computed(() => JSON.stringify(draft.value) !== JSON.stringify(store.profiles));
void store.init();
watch(
() => store.isLoaded,
(loaded) => {
// A remounted settings panel starts with an empty draft even when Pinia
// already contains profiles, so the initial empty state is not a user edit.
if (loaded && (!hasInitializedDraft.value || !isDirty.value)) {
resetDraft();
hasInitializedDraft.value = true;
}
},
{ immediate: true },
);
const selected = computed(() => draft.value.find((profile) => profile.id === selectedId.value) || null);
const selectedSsh = computed(() => (selected.value?.type === "ssh" ? selected.value : null));
const selectedProxy = computed(() => (selected.value?.type === "proxy" ? selected.value : null));
const selectedHttp = computed(() => (selected.value?.type === "http_tunnel" ? selected.value : null));
const sshConfigHostAliases = computed(() => sshConfigHosts.value.map((entry) => entry.alias));
async function loadSshConfigHosts() {
try {
sshConfigHosts.value = await api.listSshConfigHosts();
} catch {
sshConfigHosts.value = [];
}
}
function updateSelectedSshHost(value: string | number) {
if (!selectedSsh.value) return;
selectedSsh.value.host = String(value);
prefillSshConfigHostAlias(selectedSsh.value, sshConfigHosts.value);
}
void loadSshConfigHosts();
function invalidateProfileTest() {
testGuard.invalidate();
isTesting.value = false;
testResult.value = null;
}
// Profile tests are asynchronous, so any selection or configuration change
// must invalidate the request before it can publish a stale result.
watch(
[selectedId, selectedSsh, selectedProxy],
() => {
invalidateProfileTest();
},
{ deep: true },
);
function profileTypeLabel(profile: TunnelProfile): string {
if (profile.type === "proxy") return "Proxy";
if (profile.type === "http_tunnel") return t("connection.httpTunnel");
return "SSH";
}
function profileDisplayName(profile: TunnelProfile): string {
return profile.name?.trim() || tunnelProfileSummary(profile) || t("settings.tunnelsUnnamedProfile");
}
function addProfile(type: TunnelProfileType) {
const profile = createTunnelProfile(type);
draft.value = [...draft.value, profile];
selectedId.value = profile.id;
}
function removeSelected() {
const current = selected.value;
if (!current) return;
draft.value = draft.value.filter((profile) => profile.id !== current.id);
selectedId.value = draft.value[0]?.id || null;
}
function updateSshAuthMethod(value: unknown) {
const profile = selectedSsh.value;
if (!profile) return;
profile.auth_method = value === "key" ? "key" : value === "key+password" ? "key+password" : value === "none" ? "none" : "password";
if (profile.auth_method !== "password" && profile.auth_method !== "key+password") profile.password = "";
if (profile.auth_method !== "key" && profile.auth_method !== "key+password") {
profile.key_path = "";
profile.key_passphrase = "";
}
}
function updateProxyType(value: unknown) {
const profile = selectedProxy.value;
if (!profile) return;
profile.proxy_type = value === "http" ? "http" : "socks5";
}
async function browseSshKeyPath(target?: { key_path?: string } | null) {
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title: "Select SSH Private Key",
multiple: false,
});
if (selected && typeof selected === "string" && target) {
target.key_path = selected;
}
}
}
async function save() {
if (isSaving.value) return;
invalidateProfileTest();
isSaving.value = true;
try {
await store.saveProfiles(cloneProfiles(draft.value));
toast(t("settings.tunnelsSaved"));
} catch (error) {
toast(t("settings.tunnelsSaveFailed", { message: translateBackendError(t, error) }), 5000);
} finally {
isSaving.value = false;
}
}
async function testSelected() {
const profile = selectedSsh.value || selectedProxy.value;
if (!profile || isTesting.value) return;
const profileSnapshot = cloneProfiles([profile])[0];
const requestId = testGuard.start(profileSnapshot);
isTesting.value = true;
testResult.value = null;
try {
const message = await store.testProfile(profileSnapshot);
if (!testGuard.isCurrent(requestId, profile)) return;
testResult.value = { ok: true, message: message ? t("settings.tunnelsTestSuccess") + ": " + message : t("settings.tunnelsTestSuccess") };
} catch (error) {
if (!testGuard.isCurrent(requestId, profile)) return;
testResult.value = { ok: false, message: t("settings.tunnelsTestFailed", { message: translateBackendError(t, error) }) };
} finally {
if (testGuard.isCurrent(requestId, selectedSsh.value || selectedProxy.value)) isTesting.value = false;
}
}
</script>
<template>
<div class="flex flex-col gap-4">
<p class="text-xs text-muted-foreground">{{ t("settings.tunnelsDescription") }}</p>
<div class="grid min-w-0 gap-2">
<p v-if="!draft.length" class="rounded-md border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
{{ t("settings.tunnelsEmpty") }}
</p>
<button v-for="profile in draft" :key="profile.id" type="button" class="flex min-h-10 items-center gap-2 rounded-md border px-3 text-left text-xs transition-colors" :class="profile.id === selectedId ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'" @click="selectedId = profile.id">
<span class="shrink-0 rounded border bg-muted/40 px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">{{ profileTypeLabel(profile) }}</span>
<span class="min-w-0 flex-1 truncate">{{ profileDisplayName(profile) }}</span>
<span class="min-w-0 truncate text-muted-foreground">{{ tunnelProfileSummary(profile) }}</span>
</button>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" @click="addProfile('ssh')">
<Plus class="mr-1.5 h-3.5 w-3.5" />
{{ t("settings.tunnelsAddSsh") }}
</Button>
<Button type="button" variant="outline" size="sm" @click="addProfile('proxy')">
<Plus class="mr-1.5 h-3.5 w-3.5" />
{{ t("settings.tunnelsAddProxy") }}
</Button>
<Button type="button" variant="outline" size="sm" @click="addProfile('http_tunnel')">
<Plus class="mr-1.5 h-3.5 w-3.5" />
{{ t("settings.tunnelsAddHttp") }}
</Button>
<Button v-if="selected" type="button" variant="outline" size="sm" @click="removeSelected">
<Trash2 class="mr-1.5 h-3.5 w-3.5" />
{{ t("settings.tunnelsDelete") }}
</Button>
</div>
<template v-if="selected">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("settings.tunnelsProfileName") }}</Label>
<Input v-model="selected.name" class="col-span-3" :placeholder="t('settings.tunnelsProfileNamePlaceholder')" />
</div>
<template v-if="selectedSsh">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.sshHost") }}</Label>
<Input :model-value="selectedSsh.host" class="col-span-2" list="tunnel-profile-ssh-config-host-aliases" :placeholder="t('connection.sshHostPlaceholder')" @update:model-value="updateSelectedSshHost" />
<datalist id="tunnel-profile-ssh-config-host-aliases">
<option v-for="alias in sshConfigHostAliases" :key="alias" :value="alias" />
</datalist>
<Input v-model.number="selectedSsh.port" type="number" min="1" max="65535" class="col-span-1" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.sshUser") }}</Label>
<Input v-model="selectedSsh.user" class="col-span-3" placeholder="root" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.sshAuthMethod") }}</Label>
<Select :model-value="selectedSsh.auth_method || 'password'" @update:model-value="updateSshAuthMethod">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="password">{{ t("connection.sshAuthMethodPassword") }}</SelectItem>
<SelectItem value="key">{{ t("connection.sshAuthMethodKey") }}</SelectItem>
<SelectItem value="key+password">{{ t("connection.sshAuthMethodKeyPassword") }}</SelectItem>
<SelectItem value="none">{{ t("connection.sshAuthMethodNone") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="!selectedSsh.auth_method || selectedSsh.auth_method === 'password' || selectedSsh.auth_method === 'key+password'" class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.sshPassword") }}</Label>
<PasswordInput v-model="selectedSsh.password" class="col-span-3" :placeholder="t('connection.sshPasswordPlaceholder')" />
</div>
<div v-if="selectedSsh.auth_method === 'key' || selectedSsh.auth_method === 'key+password'" class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.sshKeyPath") }}</Label>
<div class="col-span-3 flex items-center gap-1">
<Input v-model="selectedSsh.key_path" class="flex-1" placeholder="~/.ssh/id_rsa" />
<Tooltip v-if="isTauriRuntime()">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseSshKeyPath(selectedSsh)">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.sshKeyPathBrowse") }}</TooltipContent>
</Tooltip>
</div>
</div>
<div v-if="selectedSsh.auth_method === 'key' || selectedSsh.auth_method === 'key+password'" class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.sshKeyPassphrase") }}</Label>
<PasswordInput v-model="selectedSsh.key_passphrase" class="col-span-3" :placeholder="t('connection.sshKeyPassphrasePlaceholder')" />
</div>
<div v-if="selectedSsh.auth_method === 'none'" class="grid grid-cols-4 items-center gap-4">
<span />
<p class="col-span-3 text-xs text-muted-foreground">{{ t("connection.sshAuthMethodNoneHint") }}</p>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<span />
<label class="col-span-3 flex cursor-pointer items-center gap-2">
<input v-model="selectedSsh.expose_lan" type="checkbox" class="mr-0" />
<span class="text-xs text-muted-foreground">{{ t("connection.sshExposeLan") }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.sshConnectTimeout") }}</Label>
<Input v-model.number="selectedSsh.connect_timeout_secs" type="number" min="1" max="300" step="1" class="col-span-3" />
</div>
</template>
<template v-else-if="selectedProxy">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.proxyType") }}</Label>
<Select :model-value="selectedProxy.proxy_type || 'socks5'" @update:model-value="updateProxyType">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="socks5">SOCKS5</SelectItem>
<SelectItem value="http">HTTP CONNECT</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.proxyHost") }}</Label>
<Input v-model="selectedProxy.host" class="col-span-2" placeholder="127.0.0.1" />
<Input v-model.number="selectedProxy.port" type="number" class="col-span-1" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.proxyUsername") }}</Label>
<Input v-model="selectedProxy.username" class="col-span-3" :placeholder="t('connection.proxyUsernamePlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.proxyPassword") }}</Label>
<PasswordInput v-model="selectedProxy.password" class="col-span-3" :placeholder="t('connection.proxyPasswordPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.proxyTestTarget") }}</Label>
<Input v-model="selectedProxy.test_target" class="col-span-3" :placeholder="t('connection.proxyTestTargetPlaceholder')" />
</div>
</template>
<template v-else-if="selectedHttp">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.httpTunnelUrl") }}</Label>
<Input v-model="selectedHttp.url" class="col-span-3" placeholder="https://dbx.example.com/dbx_tunnel.php" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.httpTunnelToken") }}</Label>
<PasswordInput v-model="selectedHttp.token" class="col-span-3" :placeholder="t('connection.httpTunnelTokenPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.httpTunnelConnectTimeout") }}</Label>
<Input v-model.number="selectedHttp.connect_timeout_secs" type="number" min="1" max="300" step="1" class="col-span-3" />
</div>
</template>
</template>
<div class="flex flex-wrap items-center gap-2">
<Button type="button" size="sm" :disabled="!isDirty || isSaving" @click="save">
<Loader2 v-if="isSaving" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
{{ t("settings.tunnelsSave") }}
</Button>
<Button type="button" variant="outline" size="sm" :disabled="!isDirty || isSaving" @click="resetDraft">
{{ t("settings.tunnelsReset") }}
</Button>
<Button v-if="selectedSsh || selectedProxy" type="button" variant="outline" size="sm" :disabled="isTesting || isSaving || (!selectedSsh?.host?.trim() && !selectedProxy?.host?.trim())" @click="testSelected">
<Loader2 v-if="isTesting" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
{{ isTesting ? t("settings.tunnelsTesting") : t("settings.tunnelsTest") }}
</Button>
<p v-if="isDirty" class="text-xs text-muted-foreground">{{ t("settings.tunnelsUnsavedHint") }}</p>
</div>
<p v-if="testResult" class="text-xs" :class="testResult.ok ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500'">
{{ testResult.message }}
</p>
</div>
</template>