feat(plugins): add interactive prompts and richer connection forms

This commit is contained in:
Jinpy authored and GitHub committed 2026-09-17 19:29:28 +08:00
1 parent fac270fc8a
commit e4bda693fb
42 files changed
+3922 -200

No files matched your search

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
import { useI18n } from "vue-i18n";
import { invoke } from "@tauri-apps/api/core";
import { TriangleAlert } from "@lucide/vue";
@@ -12,7 +12,7 @@ import { apiUrl } from "@/lib/common/webPath";
interface SshPromptRequest {
id: string;
kind: "HostKeyVerify" | "HostKeyChanged" | "SecretInput" | "WorkerUploadConsent";
kind: "HostKeyVerify" | "HostKeyChanged" | "SecretInput" | "WorkerUploadConsent" | "UserInput";
host: string;
port: number;
key_type?: string | null;
@@ -20,6 +20,14 @@ interface SshPromptRequest {
previous_fingerprint?: string | null;
prompt?: string | null;
echo?: boolean;
/** UserInput: who asked (plugin display name). */
source?: string | null;
/** UserInput: caller-provided heading, already localized by the caller. */
title?: string | null;
/** UserInput: preset answer offered in the input. */
default_value?: string | null;
/** UserInput: fixed answers; empty means free-form input. */
options?: Array<{ value: string; label: string }> | null;
}
interface SshHostKeyNotice {
@@ -39,20 +47,39 @@ const { toast } = useToast();
const queue = ref<SshPromptRequest[]>([]);
const current = computed<SshPromptRequest | null>(() => queue.value[0] ?? null);
const isSecretPrompt = computed(() => current.value?.kind === "SecretInput");
const isPluginInputPrompt = computed(() => current.value?.kind === "UserInput");
const isTextPrompt = computed(() => isSecretPrompt.value || isPluginInputPrompt.value);
const isWorkerUploadPrompt = computed(() => current.value?.kind === "WorkerUploadConsent");
const isHostKeyChanged = computed(() => current.value?.kind === "HostKeyChanged");
const promptOptions = computed(() => current.value?.options ?? []);
const titleKey = computed(() => {
if (isSecretPrompt.value) return "connection.sshInteractiveTitle";
if (isPluginInputPrompt.value) return "connection.sshPluginInputTitle";
if (isWorkerUploadPrompt.value) return "connection.sshWorkerUploadConsentTitle";
if (isHostKeyChanged.value) return "connection.sshHostKeyChangedTitle";
return "connection.sshHostKeyVerifyTitle";
});
const messageKey = computed(() => {
if (isSecretPrompt.value) return "connection.sshInteractiveMessage";
if (isPluginInputPrompt.value) return "connection.sshPluginInputMessage";
if (isWorkerUploadPrompt.value) return "connection.sshWorkerUploadConsentMessage";
if (isHostKeyChanged.value) return "connection.sshHostKeyChangedMessage";
return "connection.sshHostKeyVerifyMessage";
});
/**
* A plugin's question carries its own heading and body, so the dialog shows
* what was asked instead of the host's SSH wording. The origin line names the
* plugin so a bastion MFA prompt is never mistaken for a host-owned one.
*/
const dialogTitle = computed(() => current.value?.title?.trim() || t(titleKey.value));
const dialogDescription = computed(() => {
if (isPluginInputPrompt.value) {
const source = current.value?.source?.trim();
return source ? t("connection.sshPluginInputMessage", { source }) : t("connection.sshPluginInputMessageNoSource");
}
return t(messageKey.value, { host: current.value?.host ?? "", port: current.value?.port ?? "" });
});
const promptBody = computed(() => (isPluginInputPrompt.value ? current.value?.prompt?.trim() || t("connection.sshInteractiveDefaultPrompt") : ""));
const visible = ref(false);
const remember = ref(true);
@@ -236,7 +263,9 @@ function dismissPrompt(id: string) {
function resetPromptState() {
remember.value = true;
secretCode.value = "";
// A caller may suggest an answer (e.g. a remembered bastion account); the
// user still has to submit it.
secretCode.value = current.value?.kind === "UserInput" ? (current.value?.default_value ?? "") : "";
}
function fingerprintBody(value: string | null | undefined): string {
@@ -264,6 +293,19 @@ function reject() {
function submitSecret() {
void resolve("secret");
}
/** A fixed-choice plugin prompt is answered by picking an option. */
function submitOption(value: string) {
secretCode.value = value;
void resolve("secret");
}
// Every prompt gets a clean slate (and its own preset answer) when it reaches
// the front of the queue.
watch(
() => current.value?.id,
() => resetPromptState(),
);
</script>
<template>
@@ -279,9 +321,9 @@ function submitSecret() {
100). -->
<DialogContent class="flex max-h-[min(36rem,calc(var(--dbx-viewport-height)-2rem))] w-full max-w-[32rem] flex-col gap-4 overflow-hidden" overlay-class="z-[200]" portal-class="z-[200]" :show-close-button="false" @interact-outside.prevent @escape-key-down.prevent>
<DialogHeader class="shrink-0">
<DialogTitle>{{ t(titleKey) }}</DialogTitle>
<DialogTitle>{{ dialogTitle }}</DialogTitle>
<DialogDescription class="text-muted-foreground">
{{ t(messageKey, { host: current?.host ?? "", port: current?.port ?? "" }) }}
{{ dialogDescription }}
</DialogDescription>
</DialogHeader>
@@ -323,6 +365,24 @@ function submitSecret() {
<span>{{ current.kind === "WorkerUploadConsent" ? t("connection.sshWorkerUploadConsentRemember") : t("connection.sshHostKeyVerifyRemember") }}</span>
</label>
<div v-else-if="isPluginInputPrompt" class="space-y-3">
<p class="whitespace-pre-wrap text-sm text-muted-foreground">{{ promptBody }}</p>
<div v-if="promptOptions.length" class="flex flex-col gap-2">
<Button v-for="option in promptOptions" :key="option.value" variant="outline" class="h-auto w-full justify-start py-2 text-left whitespace-normal" :disabled="resolving" @click="submitOption(option.value)">
{{ option.label }}
</Button>
</div>
<input
v-else
v-model="secretCode"
:type="current.echo ? 'text' : 'password'"
autocomplete="one-time-code"
class="w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm outline-none focus:ring-2 focus:ring-ring"
:placeholder="t('connection.sshInteractivePlaceholder')"
@keydown.enter="submitSecret"
/>
</div>
<div v-else-if="current.kind === 'SecretInput'" class="space-y-2">
<p class="whitespace-pre-wrap text-sm text-muted-foreground">
{{ current.prompt || t("connection.sshInteractiveDefaultPrompt") }}
@@ -340,7 +400,7 @@ function submitSecret() {
<DialogFooter class="shrink-0">
<Button variant="outline" :disabled="resolving" @click="reject">
{{ t(isSecretPrompt ? "connection.sshInteractiveCancel" : isWorkerUploadPrompt ? "connection.sshWorkerUploadConsentReject" : isHostKeyChanged ? "connection.sshHostKeyChangedClose" : "connection.sshHostKeyVerifyReject") }}
{{ t(isTextPrompt ? "connection.sshInteractiveCancel" : isWorkerUploadPrompt ? "connection.sshWorkerUploadConsentReject" : isHostKeyChanged ? "connection.sshHostKeyChangedClose" : "connection.sshHostKeyVerifyReject") }}
</Button>
<template v-if="isHostKeyChanged">
<Button variant="outline" :disabled="resolving" @click="acceptSessionOnly">
@@ -350,11 +410,13 @@ function submitSecret() {
{{ t("connection.sshHostKeyChangedUpdate") }}
</Button>
</template>
<Button v-else-if="current?.kind !== 'SecretInput'" :disabled="resolving" @click="accept">
{{ t(isWorkerUploadPrompt ? "connection.sshWorkerUploadConsentAccept" : "connection.sshHostKeyVerifyAccept") }}
<!-- A fixed-choice plugin prompt is answered by the option buttons in
the body, so it needs no submit button here. -->
<Button v-else-if="isTextPrompt && !(isPluginInputPrompt && promptOptions.length)" :disabled="resolving || !secretCode" @click="submitSecret">
{{ t(isSecretPrompt ? "connection.sshInteractiveSubmit" : "connection.sshPluginInputSubmit") }}
</Button>
<Button v-else :disabled="resolving || !secretCode" @click="submitSecret">
{{ t("connection.sshInteractiveSubmit") }}
<Button v-else-if="!isTextPrompt" :disabled="resolving" @click="accept">
{{ t(isWorkerUploadPrompt ? "connection.sshWorkerUploadConsentAccept" : "connection.sshHostKeyVerifyAccept") }}
</Button>
</DialogFooter>
</DialogContent>
@@ -432,4 +432,122 @@ describe("SshHostKeyPromptDialog web bridge", () => {
});
});
});
it("relays a plugin question and submits the typed answer", async () => {
// Host API `host/requestUserInput`: a plugin backend (e.g. the SSH plugin
// answering a bastion's keyboard-interactive MFA) asks through the same
// dialog, and the host only carries the question and the typed value.
await mountDialog();
const eventSource = MockEventSource.instances[0];
eventSource?.emit({
type: "prompt",
request: {
id: "plugin-input-1",
kind: "UserInput",
host: "",
port: 0,
prompt: "Verification code (6 digits)",
title: "JumpServer login",
source: "SSH Terminal",
echo: false,
default_value: "12",
},
});
await nextTick();
expect(document.body.textContent).toContain("JumpServer login");
expect(document.body.textContent).toContain("SSH Terminal needs input before it can continue.");
expect(document.body.textContent).toContain("Verification code (6 digits)");
const input = document.body.querySelector<HTMLInputElement>("input");
expect(input?.type).toBe("password");
// A caller-provided default is offered but still has to be submitted.
expect(input?.value).toBe("12");
if (!input) throw new Error("plugin prompt input was not rendered");
input.value = "654321";
input.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
const buttons = [...document.body.querySelectorAll<HTMLButtonElement>("button")];
expect(buttons.map((button) => button.textContent?.trim())).toEqual(["Cancel", "Submit"]);
buttons[buttons.length - 1].click();
await vi.waitFor(() => {
expect(resolveSshPromptMock).toHaveBeenCalledWith({
id: "plugin-input-1",
action: "secret",
remember: undefined,
secret: "654321",
});
});
});
it("answers a fixed-choice plugin question by picking an option", async () => {
await mountDialog();
const eventSource = MockEventSource.instances[0];
eventSource?.emit({
type: "prompt",
request: {
id: "plugin-choice-1",
kind: "UserInput",
host: "",
port: 0,
title: "Select account",
prompt: "Which bastion account should be used?",
source: "LDAP Directory",
options: [
{ value: "jinpy", label: "jinpy (admin)" },
{ value: "deploy", label: "deploy (read-only)" },
],
},
});
await nextTick();
expect(document.body.textContent).toContain("Which bastion account should be used?");
const buttons = [...document.body.querySelectorAll<HTMLButtonElement>("button")];
// Options replace the free-form input, so only the choices plus Cancel show.
expect(buttons.map((button) => button.textContent?.trim())).toEqual(["jinpy (admin)", "deploy (read-only)", "Cancel"]);
expect(document.body.querySelector("input")).toBeNull();
buttons.find((button) => button.textContent?.trim() === "deploy (read-only)")?.click();
await vi.waitFor(() => {
expect(resolveSshPromptMock).toHaveBeenCalledWith({
id: "plugin-choice-1",
action: "secret",
remember: undefined,
secret: "deploy",
});
});
});
it("cancels a plugin question without answering it", async () => {
await mountDialog();
const eventSource = MockEventSource.instances[0];
eventSource?.emit({
type: "prompt",
request: {
id: "plugin-input-cancel",
kind: "UserInput",
host: "",
port: 0,
prompt: "Verification code",
source: "SSH Terminal",
},
});
await nextTick();
const cancel = [...document.body.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "Cancel");
cancel?.click();
await vi.waitFor(() => {
expect(resolveSshPromptMock).toHaveBeenCalledWith({
id: "plugin-input-cancel",
action: "reject",
remember: undefined,
secret: undefined,
});
});
});
});