feat(plugins): add interactive prompts and richer connection forms
This commit is contained in:
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user