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
@@ -3610,7 +3610,7 @@ const connectionLabelSmallPaddedClass = `${connectionLabelClass} pt-2 text-xs`;
|
||||
|
||||
function pluginFieldValue(field: PluginFormField): PluginFormFieldValue {
|
||||
if (field.binding === "name") return form.value.name;
|
||||
return pluginFormValues.value[field.key] ?? field.default;
|
||||
return pluginFormValues.value[field.key] ?? field.default ?? undefined;
|
||||
}
|
||||
|
||||
function pluginFieldHasValue(field: PluginFormField): boolean {
|
||||
|
||||
@@ -4,9 +4,19 @@ import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import PluginConnectionFields from "./PluginConnectionFields.vue";
|
||||
import type { PluginConnectionProviderContribution, PluginFormFieldBinding, PluginFormFieldValue } from "@/types/database";
|
||||
import type { PluginConnectionProviderContribution, PluginFormField, PluginFormFieldBinding, PluginFormFieldValue } from "@/types/database";
|
||||
|
||||
const invokePluginMock = vi.fn();
|
||||
const { pickPluginFieldFileMock, tauriRuntime } = vi.hoisted(() => ({
|
||||
pickPluginFieldFileMock: vi.fn(),
|
||||
tauriRuntime: { value: false },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/plugins/pluginFieldPicker", () => ({
|
||||
PLUGIN_PICKER_MAX_BYTES: 1_048_576,
|
||||
pickPluginFieldFile: (...args: unknown[]) => pickPluginFieldFileMock(...args),
|
||||
}));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => tauriRuntime.value }));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
listLocalSshKeys: vi.fn(async () => [
|
||||
@@ -56,6 +66,72 @@ async function mountFields(initialValues: Record<string, PluginFormFieldValue> =
|
||||
return state;
|
||||
}
|
||||
|
||||
describe("PluginConnectionFields file picker", () => {
|
||||
const pickerContribution = (picker: PluginFormField["picker"], contentField = true): PluginConnectionProviderContribution => ({
|
||||
type: "connection-provider",
|
||||
id: "ssh.connection",
|
||||
label: "SSH",
|
||||
database_type: "ssh",
|
||||
fields: [{ key: "private_key_path", label: "Private key path", type: "text", picker }, ...(contentField ? [{ key: "private_key", label: "Private key", type: "textarea" as const, binding: "secret" as const }] : [])],
|
||||
});
|
||||
|
||||
it("stores the chosen client path on desktop and drops a stale uploaded copy", async () => {
|
||||
tauriRuntime.value = true;
|
||||
pickPluginFieldFileMock.mockReset().mockResolvedValue({ path: "/Users/dev/.ssh/id_rsa", name: "id_rsa" });
|
||||
const provider = pickerContribution({ kind: "file", accept: [".pem"], content_field: "private_key" });
|
||||
const state = await mountContribution(provider, { private_key: "OLD-UPLOADED-KEY" });
|
||||
|
||||
const browse = document.querySelector<HTMLButtonElement>("#ssh-connection-private_key_path-picker");
|
||||
expect(browse?.textContent).toContain("Choose file");
|
||||
browse?.click();
|
||||
|
||||
await vi.waitFor(() => expect(state.values.private_key_path).toBe("/Users/dev/.ssh/id_rsa"));
|
||||
// The plugin prefers content over a path, so picking a path must clear the
|
||||
// previously uploaded key or the stale one would keep winning.
|
||||
expect(state.values.private_key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uploads the file content into the paired field on browser hosts", async () => {
|
||||
tauriRuntime.value = false;
|
||||
pickPluginFieldFileMock.mockReset().mockResolvedValue({ content: "UPLOADED-KEY", name: "id_rsa" });
|
||||
const provider = pickerContribution({ kind: "file", accept: [".pem"], content_field: "private_key" });
|
||||
const state = await mountContribution(provider, { private_key_path: "/Users/dev/.ssh/id_rsa" });
|
||||
|
||||
const upload = document.querySelector<HTMLButtonElement>("#ssh-connection-private_key_path-picker");
|
||||
expect(upload?.textContent).toContain("Upload file");
|
||||
upload?.click();
|
||||
|
||||
await vi.waitFor(() => expect(state.values.private_key).toBe("UPLOADED-KEY"));
|
||||
// A browser path would point at the user's machine, not the host's.
|
||||
expect(state.values.private_key_path).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hides a browser-unsupported picker and keeps the desktop one available", async () => {
|
||||
const provider = pickerContribution({ kind: "file" }, false);
|
||||
|
||||
tauriRuntime.value = false;
|
||||
await mountContribution(provider, {});
|
||||
expect(document.querySelector("#ssh-connection-private_key_path-picker")).toBeNull();
|
||||
|
||||
tauriRuntime.value = true;
|
||||
await mountContribution(provider, {});
|
||||
expect(document.querySelector("#ssh-connection-private_key_path-picker")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces a picker failure without touching the form", async () => {
|
||||
tauriRuntime.value = false;
|
||||
pickPluginFieldFileMock.mockReset().mockRejectedValue(new Error("File is larger than 1024 KiB"));
|
||||
const provider = pickerContribution({ kind: "file", content_field: "private_key" });
|
||||
const state = await mountContribution(provider, { private_key: "KEEP-ME" });
|
||||
|
||||
document.querySelector<HTMLButtonElement>("#ssh-connection-private_key_path-picker")?.click();
|
||||
|
||||
await vi.waitFor(() => expect(pickPluginFieldFileMock).toHaveBeenCalled());
|
||||
await nextTick();
|
||||
expect(state.values).toEqual({ private_key: "KEEP-ME" });
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
@@ -111,6 +187,39 @@ describe("PluginConnectionFields", () => {
|
||||
expect(state.values).toEqual({ password: "secret", host: "db.internal" });
|
||||
});
|
||||
|
||||
it('renders a null value as an empty field instead of the text "null"', async () => {
|
||||
// Hosts before the manifest serialization fix hydrated untouched fields with
|
||||
// `null`, and a null reaching an <input> is coerced to the string "null" by
|
||||
// the DOM — a password field then looked non-empty without any user input.
|
||||
const provider: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "null.connection",
|
||||
label: "Nulls",
|
||||
database_type: "nulls",
|
||||
fields: [
|
||||
{ key: "sudo_password", label: "Sudo password", type: "password", binding: "secret" },
|
||||
{ key: "sudo_command", label: "Sudo command", type: "text", binding: "config" },
|
||||
{ key: "set_env", label: "Env", type: "textarea", binding: "config" },
|
||||
],
|
||||
};
|
||||
const state = await mountContribution(provider, {
|
||||
sudo_password: null,
|
||||
sudo_command: null,
|
||||
set_env: null,
|
||||
} as unknown as Record<string, PluginFormFieldValue>);
|
||||
|
||||
expect(document.querySelector<HTMLInputElement>("#null-connection-sudo_password")?.value).toBe("");
|
||||
expect(document.querySelector<HTMLInputElement>("#null-connection-sudo_command")?.value).toBe("");
|
||||
expect(document.querySelector<HTMLTextAreaElement>("#null-connection-set_env")?.value).toBe("");
|
||||
|
||||
// Editing still emits a real value, so a null never survives the dialog.
|
||||
const command = document.querySelector<HTMLInputElement>("#null-connection-sudo_command")!;
|
||||
command.value = "sudo -n true";
|
||||
command.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
expect(state.values.sudo_command).toBe("sudo -n true");
|
||||
});
|
||||
|
||||
it("can hide host-owned common bindings", async () => {
|
||||
contribution.fields[0].binding = "host";
|
||||
await mountFields({}, ["host"]);
|
||||
@@ -222,6 +331,69 @@ describe("PluginConnectionFields", () => {
|
||||
expect(document.querySelector("#kafka-connection-msk_region")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a field only while every clause of an all_of condition holds (sudo_source + read_only)", async () => {
|
||||
// The SSH plugin cannot express "sudo_source = custom AND read_only =
|
||||
// false" with one clause per field; all_of + a boolean literal covers it.
|
||||
const ssh: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "ssh.connection",
|
||||
label: "SSH",
|
||||
database_type: "ssh",
|
||||
fields: [
|
||||
{
|
||||
key: "read_only",
|
||||
label: "Read only",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
key: "sudo_source",
|
||||
label: "Sudo source",
|
||||
type: "select",
|
||||
default: "none",
|
||||
options: [
|
||||
{ label: "None", value: "none" },
|
||||
{ label: "Custom", value: "custom" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "sudo_command",
|
||||
label: "Sudo command",
|
||||
type: "text",
|
||||
visible_when: {
|
||||
all_of: [
|
||||
{ field: "sudo_source", one_of: ["custom"] },
|
||||
{ field: "read_only", one_of: [false] },
|
||||
],
|
||||
},
|
||||
required_when: {
|
||||
all_of: [
|
||||
{ field: "sudo_source", one_of: ["custom"] },
|
||||
{ field: "read_only", one_of: [false] },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const state = await mountContribution(ssh, {});
|
||||
|
||||
expect(document.querySelector("#ssh-connection-sudo_command")).toBeNull();
|
||||
|
||||
state.values = { sudo_source: "custom", read_only: false };
|
||||
await nextTick();
|
||||
expect(document.querySelector("#ssh-connection-sudo_command")).not.toBeNull();
|
||||
expect(document.querySelector('label[for="ssh-connection-sudo_command"]')?.textContent).toContain("*");
|
||||
|
||||
// Read-only sessions ignore the sudo block entirely.
|
||||
state.values = { sudo_source: "custom", read_only: true };
|
||||
await nextTick();
|
||||
expect(document.querySelector("#ssh-connection-sudo_command")).toBeNull();
|
||||
|
||||
state.values = { sudo_source: "none", read_only: false };
|
||||
await nextTick();
|
||||
expect(document.querySelector("#ssh-connection-sudo_command")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers local SSH keys on private_key_path fields and fills the chosen path", async () => {
|
||||
const withKey: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { FolderOpen } from "@lucide/vue";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import PasswordInput from "@/components/ui/PasswordInput.vue";
|
||||
import PasswordTextarea from "@/components/ui/PasswordTextarea.vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { pluginFieldIsRequired, pluginFieldIsVisible, type PluginFieldResolver } from "@/lib/plugins/pluginFieldConditions";
|
||||
import { pickPluginFieldFile } from "@/lib/plugins/pluginFieldPicker";
|
||||
import { invokePlugin, listLocalSshKeys } from "@/lib/backend/api";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import type { LocalSshKey, PluginConnectionProviderContribution, PluginFormField, PluginFormFieldBinding, PluginFormFieldOption, PluginFormFieldValue } from "@/types/database";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -24,12 +29,13 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
|
||||
const formValues = computed(() => props.modelValue);
|
||||
/** Value of any sibling field by key (condition evaluation reads current form values). */
|
||||
function readFieldValue(key: string): PluginFormFieldValue {
|
||||
const field = props.contribution.fields.find((candidate) => candidate.key === key);
|
||||
return field ? (formValues.value[key] ?? field.default) : undefined;
|
||||
return field ? (formValues.value[key] ?? field.default ?? undefined) : undefined;
|
||||
}
|
||||
const resolveSiblingField: PluginFieldResolver = (key) => props.contribution.fields.find((candidate) => candidate.key === key);
|
||||
function fieldVisible(field: PluginFormField): boolean {
|
||||
@@ -43,7 +49,18 @@ const visibleFields = computed(() => props.contribution.fields.filter((field) =>
|
||||
const isConnectionDialogLayout = computed(() => props.layout === "connection-dialog");
|
||||
|
||||
function fieldValue(field: PluginFormField): PluginFormFieldValue {
|
||||
return formValues.value[field.key] ?? field.default ?? defaultValueFor(field);
|
||||
// `null` is "unset": hosts used to hydrate untouched fields with a null
|
||||
// default, and a null reaching an input must not look like a stored value.
|
||||
const raw = formValues.value[field.key];
|
||||
const value = raw ?? field.default ?? defaultValueFor(field);
|
||||
return value === null ? undefined : value;
|
||||
}
|
||||
|
||||
/** Text/number inputs render any primitive as text; `null` stays empty. */
|
||||
function fieldInputValue(field: PluginFormField): string | number {
|
||||
const value = fieldValue(field);
|
||||
if (typeof value === "boolean") return String(value);
|
||||
return value ?? "";
|
||||
}
|
||||
|
||||
function updateField(field: PluginFormField, value: PluginFormFieldValue) {
|
||||
@@ -174,7 +191,74 @@ function applyLocalSshKey(field: PluginFormField, event: Event) {
|
||||
const selected = localSshKeys.value.find((key) => key.path === select.value);
|
||||
select.value = "";
|
||||
if (!selected) return;
|
||||
updateField(field, selected.path);
|
||||
pickPluginFieldPath(field, selected.path);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manifest-declared local file action (`picker`, Host API 1.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Fields whose picker acts on the working copy; the plugin reads the file. */
|
||||
function pickerField(field: PluginFormField) {
|
||||
return field.picker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser hosts cannot hand a path to the plugin, so the picker uploads the
|
||||
* file content into the declared `content_field` instead. A picker without a
|
||||
* `content_field` is desktop-only and stays hidden in the browser.
|
||||
*/
|
||||
function pickerVisible(field: PluginFormField): boolean {
|
||||
const picker = pickerField(field);
|
||||
if (!picker) return false;
|
||||
if (isTauriRuntime()) return true;
|
||||
return picker.kind === "file" && Boolean(picker.content_field);
|
||||
}
|
||||
|
||||
const pickerBusyField = ref<string | null>(null);
|
||||
|
||||
function pickerLabelKey(field: PluginFormField): string {
|
||||
const picker = pickerField(field);
|
||||
if (picker?.kind === "directory") return isTauriRuntime() ? "connection.pluginFieldSelectDirectory" : "connection.pluginFieldSelectFile";
|
||||
return isTauriRuntime() ? "connection.pluginFieldSelectFile" : "connection.pluginFieldUploadFile";
|
||||
}
|
||||
|
||||
/** Path selection (desktop) and the SSH key dropdown share this entry point. */
|
||||
function pickPluginFieldPath(field: PluginFormField, path: string) {
|
||||
const picker = pickerField(field);
|
||||
const next = { ...formValues.value, [field.key]: path };
|
||||
// The plugin treats content as more specific than a path, so selecting a path
|
||||
// must clear the uploaded copy — otherwise a stale upload keeps winning.
|
||||
if (picker?.content_field) delete next[picker.content_field];
|
||||
emit("update:modelValue", next);
|
||||
}
|
||||
|
||||
/** Upload content into the paired field and clear the (meaningless) path. */
|
||||
function pickPluginFieldContent(field: PluginFormField, contentKey: string, content: string) {
|
||||
const next = { ...formValues.value, [contentKey]: content };
|
||||
delete next[field.key];
|
||||
emit("update:modelValue", next);
|
||||
}
|
||||
|
||||
async function runPluginFieldPicker(field: PluginFormField) {
|
||||
const picker = pickerField(field);
|
||||
if (!picker || pickerBusyField.value) return;
|
||||
pickerBusyField.value = field.key;
|
||||
try {
|
||||
const picked = await pickPluginFieldFile(picker);
|
||||
if (!picked) return;
|
||||
if (picked.path !== undefined) {
|
||||
pickPluginFieldPath(field, picked.path);
|
||||
return;
|
||||
}
|
||||
if (picked.content !== undefined && picker.content_field) {
|
||||
pickPluginFieldContent(field, picker.content_field, picked.content);
|
||||
}
|
||||
} catch (error) {
|
||||
toast(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
pickerBusyField.value = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -207,7 +291,7 @@ function applyLocalSshKey(field: PluginFormField, event: Event) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-else-if="isLocalSshKeyField(field)" class="flex items-center gap-1.5">
|
||||
<Input :id="fieldId(field)" type="text" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" class="min-w-0 flex-1" @update:model-value="updateTextField(field, $event)" />
|
||||
<Input :id="fieldId(field)" type="text" :model-value="fieldInputValue(field)" :placeholder="field.placeholder" class="min-w-0 flex-1" @update:model-value="updateTextField(field, $event)" />
|
||||
<select
|
||||
class="h-9 w-40 shrink-0 rounded-md border border-input bg-transparent px-2 text-xs outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="localSshKeys.length === 0"
|
||||
@@ -219,7 +303,7 @@ function applyLocalSshKey(field: PluginFormField, event: Event) {
|
||||
<option v-for="key in localSshKeys" :key="key.path" :value="key.path" :title="key.path">{{ localSshKeyLabel(key) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<Input v-else :id="fieldId(field)" :type="field.type === 'number' ? 'number' : 'text'" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" @update:model-value="updateTextField(field, $event)" />
|
||||
<Input v-else :id="fieldId(field)" :type="field.type === 'number' ? 'number' : 'text'" :model-value="fieldInputValue(field)" :placeholder="field.placeholder" @update:model-value="updateTextField(field, $event)" />
|
||||
</template>
|
||||
<PasswordInput v-else-if="field.type === 'password'" :id="fieldId(field)" :model-value="String(fieldValue(field) ?? '')" :placeholder="field.placeholder" @update:model-value="updateField(field, $event)" />
|
||||
<PasswordTextarea v-else-if="field.type === 'textarea' && field.binding === 'secret'" :id="fieldId(field)" :model-value="String(fieldValue(field) ?? '')" :placeholder="field.placeholder" @update:model-value="updateField(field, $event)" />
|
||||
@@ -244,6 +328,12 @@ function applyLocalSshKey(field: PluginFormField, event: Event) {
|
||||
<div v-else-if="field.type === 'boolean'" class="flex h-9 items-center">
|
||||
<Switch :id="fieldId(field)" :model-value="Boolean(fieldValue(field))" size="sm" @update:model-value="updateBooleanField(field, $event)" />
|
||||
</div>
|
||||
<div v-if="pickerVisible(field)" class="flex items-center gap-2">
|
||||
<Button :id="`${fieldId(field)}-picker`" variant="outline" size="sm" class="h-8 gap-1.5 text-xs" :disabled="pickerBusyField === field.key" @click="runPluginFieldPicker(field)">
|
||||
<FolderOpen class="size-3.5" aria-hidden="true" />
|
||||
{{ t(pickerLabelKey(field)) }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="field.description" class="text-[11px] leading-5 text-muted-foreground">{{ field.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1183,6 +1183,13 @@ export default withEnglishFallback({
|
||||
sshInteractivePlaceholder: "Yoxlama kodunu daxil edin",
|
||||
sshInteractiveSubmit: "Yoxla",
|
||||
sshInteractiveCancel: "Ləğv et",
|
||||
pluginFieldSelectFile: "Fayl seç…",
|
||||
pluginFieldUploadFile: "Fayl yüklə…",
|
||||
pluginFieldSelectDirectory: "Qovluq seç…",
|
||||
sshPluginInputTitle: "Giriş tələb olunur",
|
||||
sshPluginInputMessage: "Davam etmək üçün {source} giriş tələb edir.",
|
||||
sshPluginInputMessageNoSource: "Davam etmək üçün plagin giriş tələb edir.",
|
||||
sshPluginInputSubmit: "Göndər",
|
||||
sshHostKeyNoticeChanged: "{host}:{port} üçün host açarı dəyişib — bu, araya girən tərəfin hücumuna işarə edə bilər. Yalnız bu dəyişikliyi gözləyirsinizsə, köhnə qeydi silin və yenidən əlaqə qurun.",
|
||||
sshHostKeyNoticeRejected: "{host}:{port} üçün host açarını rədd etdiniz; əlaqə dayandırıldı və kimlik məlumatları göndərilmədi.",
|
||||
sshHostKeyNoticeLearnFailed: "{host}:{port} üçün host açarını yadda saxlamaq mümkün olmadı — hosta yalnız bu sessiya üçün etibar edilir və növbəti dəfə onu yenidən yoxlamağınız istəniləcək.",
|
||||
|
||||
@@ -1146,6 +1146,9 @@ export default {
|
||||
pluginProviderUnavailable: "This plugin connection provider is unavailable. Reinstall or enable the plugin, then try again.",
|
||||
pluginRequiredField: "{field} is required",
|
||||
pluginSshKeyPickerPlaceholder: "Pick local SSH key…",
|
||||
pluginFieldSelectFile: "Choose file…",
|
||||
pluginFieldUploadFile: "Upload file…",
|
||||
pluginFieldSelectDirectory: "Choose folder…",
|
||||
pluginSshKeyPickerEmpty: "No local SSH keys found",
|
||||
pluginSshKeyEncrypted: "encrypted",
|
||||
jdbcConnection: "JDBC connection",
|
||||
@@ -1328,6 +1331,10 @@ export default {
|
||||
sshInteractivePlaceholder: "Enter verification code",
|
||||
sshInteractiveSubmit: "Verify",
|
||||
sshInteractiveCancel: "Cancel",
|
||||
sshPluginInputTitle: "Input Required",
|
||||
sshPluginInputMessage: "{source} needs input before it can continue.",
|
||||
sshPluginInputMessageNoSource: "A plugin needs input before it can continue.",
|
||||
sshPluginInputSubmit: "Submit",
|
||||
sshHostKeyNoticeChanged: "Host key for {host}:{port} changed — this may indicate a man-in-the-middle attack. Remove the old entry and reconnect only if you expect this change.",
|
||||
sshHostKeyNoticeRejected: "You rejected the host key for {host}:{port}; the connection was aborted and no credentials were sent.",
|
||||
sshHostKeyNoticeLearnFailed: "Could not save the host key for {host}:{port} — the host is trusted for this session only and you will be asked to verify it again next time.",
|
||||
|
||||
@@ -1298,6 +1298,10 @@ export default withEnglishFallback({
|
||||
sshInteractivePlaceholder: "Introducir código de verificación",
|
||||
sshInteractiveSubmit: "Verificar",
|
||||
sshInteractiveCancel: "Cancelar",
|
||||
sshPluginInputTitle: "Se requiere entrada",
|
||||
sshPluginInputMessage: "{source} necesita una entrada para continuar.",
|
||||
sshPluginInputMessageNoSource: "Un complemento necesita una entrada para continuar.",
|
||||
sshPluginInputSubmit: "Enviar",
|
||||
sshHostKeyNoticeChanged: "La clave del host {host}:{port} ha cambiado, puede haber un ataque de intermediario. Si es esperado, elimine el registro antiguo y vuelva a conectar.",
|
||||
sshHostKeyNoticeRejected: "Ha rechazado la verificación de la clave del host {host}:{port}. La conexión se ha abortado, no se enviaron credenciales.",
|
||||
sshHostKeyNoticeGeneric: "Falló la verificación de la clave del host SSH {host}:{port}.",
|
||||
@@ -1416,6 +1420,9 @@ export default withEnglishFallback({
|
||||
doltShowSystemTablesHint: "Muestra las tablas integradas de control de versiones de Dolt en la lista de tablas de la conexión actual.",
|
||||
clientCertHint: "Cuando se requiere mTLS, el certificado de cliente y la clave privada deben completarse juntos.",
|
||||
pluginSshKeyPickerPlaceholder: "Seleccionar clave SSH local…",
|
||||
pluginFieldSelectFile: "Elegir archivo…",
|
||||
pluginFieldUploadFile: "Subir archivo…",
|
||||
pluginFieldSelectDirectory: "Elegir carpeta…",
|
||||
pluginSshKeyPickerEmpty: "No se encontraron claves",
|
||||
pluginSshKeyEncrypted: "Cifrado",
|
||||
objectCacheRefreshFailed: "Error al actualizar la caché de objetos: {message}",
|
||||
|
||||
@@ -1296,6 +1296,10 @@ export default withEnglishFallback({
|
||||
sshInteractivePlaceholder: "Inserisci il codice di verifica",
|
||||
sshInteractiveSubmit: "Verifica",
|
||||
sshInteractiveCancel: "Annulla",
|
||||
sshPluginInputTitle: "Input richiesto",
|
||||
sshPluginInputMessage: "{source} richiede un input per continuare.",
|
||||
sshPluginInputMessageNoSource: "Un plugin richiede un input per continuare.",
|
||||
sshPluginInputSubmit: "Invia",
|
||||
sshHostKeyNoticeChanged: "La chiave dell'host {host}:{port} è cambiata, potrebbe esserci un attacco man-in-the-middle. Se il cambiamento è previsto, elimina il vecchio record e riconnettiti.",
|
||||
sshHostKeyNoticeRejected: "Hai rifiutato la verifica della chiave host di {host}:{port}, la connessione è stata interrotta e le credenziali non sono state inviate.",
|
||||
sshHostKeyNoticeGeneric: "Verifica della chiave host SSH {host}:{port} fallita.",
|
||||
@@ -1414,6 +1418,9 @@ export default withEnglishFallback({
|
||||
doltShowSystemTablesHint: "Mostra le tabelle di controllo versione integrate di Dolt nell'elenco delle tabelle della connessione corrente.",
|
||||
clientCertHint: "Quando è richiesto mTLS, il certificato client e la chiave privata devono essere inseriti insieme.",
|
||||
pluginSshKeyPickerPlaceholder: "Seleziona chiave SSH locale…",
|
||||
pluginFieldSelectFile: "Scegli file…",
|
||||
pluginFieldUploadFile: "Carica file…",
|
||||
pluginFieldSelectDirectory: "Scegli cartella…",
|
||||
pluginSshKeyPickerEmpty: "Nessuna chiave trovata",
|
||||
pluginSshKeyEncrypted: "Crittografata",
|
||||
objectCacheRefreshFailed: "Aggiornamento della cache degli oggetti non riuscito: {message}",
|
||||
|
||||
@@ -1296,6 +1296,10 @@ export default withEnglishFallback({
|
||||
sshInteractivePlaceholder: "検証コードを入力",
|
||||
sshInteractiveSubmit: "検証",
|
||||
sshInteractiveCancel: "キャンセル",
|
||||
sshPluginInputTitle: "入力が必要です",
|
||||
sshPluginInputMessage: "続行するには {source} への入力が必要です。",
|
||||
sshPluginInputMessageNoSource: "続行するにはプラグインへの入力が必要です。",
|
||||
sshPluginInputSubmit: "送信",
|
||||
sshHostKeyNoticeChanged: "ホスト {host}:{port} のキーが変更されました。中間者攻撃の可能性があります。予期された変更である場合は、古いレコードを削除して再接続してください。",
|
||||
sshHostKeyNoticeRejected: "ホスト {host}:{port} のホストキー検証を拒否しました。接続は中止され、資格情報は送信されませんでした。",
|
||||
sshHostKeyNoticeGeneric: "SSHホスト {host}:{port} のキー検証に失敗しました。",
|
||||
@@ -1435,6 +1439,9 @@ export default withEnglishFallback({
|
||||
doltShowSystemTablesHint: "現在の接続のテーブルリストに Dolt 組み込みのバージョン管理テーブルを表示します。",
|
||||
clientCertHint: "mTLS が要求される場合、クライアント証明書と秘密鍵は一緒に入力する必要があります。",
|
||||
pluginSshKeyPickerPlaceholder: "ローカルの SSH キーを選択…",
|
||||
pluginFieldSelectFile: "ファイルを選択…",
|
||||
pluginFieldUploadFile: "ファイルをアップロード…",
|
||||
pluginFieldSelectDirectory: "フォルダーを選択…",
|
||||
pluginSshKeyPickerEmpty: "キーが見つかりません",
|
||||
pluginSshKeyEncrypted: "暗号化済み",
|
||||
objectCacheRefreshFailed: "オブジェクトキャッシュの更新に失敗しました:{message}",
|
||||
|
||||
@@ -1212,6 +1212,10 @@ export default withEnglishFallback({
|
||||
sshInteractivePlaceholder: "인증 코드 입력",
|
||||
sshInteractiveSubmit: "인증",
|
||||
sshInteractiveCancel: "취소",
|
||||
sshPluginInputTitle: "입력 필요",
|
||||
sshPluginInputMessage: "계속하려면 {source}에 입력해야 합니다.",
|
||||
sshPluginInputMessageNoSource: "계속하려면 플러그인에 입력해야 합니다.",
|
||||
sshPluginInputSubmit: "제출",
|
||||
sshHostKeyNoticeChanged: "{host}:{port}의 호스트 키가 변경되었습니다 — 이는 중간자 공격을 나타낼 수 있습니다. 이 변경을 예상한 경우에만 이전 항목을 제거하고 다시 연결하세요.",
|
||||
sshHostKeyNoticeRejected: "{host}:{port}의 호스트 키를 거부했습니다. 연결이 중단되고 자격 증명이 전송되지 않았습니다.",
|
||||
sshHostKeyNoticeLearnFailed: "{host}:{port}의 호스트 키를 저장할 수 없습니다 — 이 호스트는 이 세션에만 신뢰되며 다음에 다시 확인을 요청받습니다.",
|
||||
@@ -1333,6 +1337,9 @@ export default withEnglishFallback({
|
||||
pluginProviderUnavailable: "이 플러그인 연결 제공자를 사용할 수 없습니다. 플러그인을 다시 설치하거나 활성화한 후 다시 시도하세요.",
|
||||
pluginRequiredField: "{field}을(를) 입력하세요.",
|
||||
pluginSshKeyPickerPlaceholder: "로컬 SSH 키 선택…",
|
||||
pluginFieldSelectFile: "파일 선택…",
|
||||
pluginFieldUploadFile: "파일 업로드…",
|
||||
pluginFieldSelectDirectory: "폴더 선택…",
|
||||
pluginSshKeyPickerEmpty: "키를 찾을 수 없음",
|
||||
pluginSshKeyEncrypted: "암호화됨",
|
||||
objectCacheRefreshFailed: "개체 캐시 새로 고침 실패: {message}",
|
||||
|
||||
@@ -1297,6 +1297,10 @@ export default withEnglishFallback({
|
||||
sshInteractivePlaceholder: "Digite o código de verificação",
|
||||
sshInteractiveSubmit: "Verificar",
|
||||
sshInteractiveCancel: "Cancelar",
|
||||
sshPluginInputTitle: "Entrada necessária",
|
||||
sshPluginInputMessage: "{source} precisa de uma entrada para continuar.",
|
||||
sshPluginInputMessageNoSource: "Um plugin precisa de uma entrada para continuar.",
|
||||
sshPluginInputSubmit: "Enviar",
|
||||
sshHostKeyNoticeChanged: "A chave do host {host}:{port} foi alterada, pode haver um ataque man-in-the-middle. Se esta alteração era esperada, exclua o registro antigo e reconecte.",
|
||||
sshHostKeyNoticeRejected: "Você rejeitou a verificação da chave do host {host}:{port}. A conexão foi abortada e as credenciais não foram enviadas.",
|
||||
sshHostKeyNoticeGeneric: "Falha na verificação da chave do host SSH {host}:{port}.",
|
||||
@@ -1415,6 +1419,9 @@ export default withEnglishFallback({
|
||||
doltShowSystemTablesHint: "Exibir as tabelas de controle de versão internas do Dolt na lista de tabelas da conexão atual.",
|
||||
clientCertHint: "Quando o mTLS for exigido, o certificado do cliente e a chave privada devem ser preenchidos juntos.",
|
||||
pluginSshKeyPickerPlaceholder: "Selecionar chave SSH local…",
|
||||
pluginFieldSelectFile: "Escolher arquivo…",
|
||||
pluginFieldUploadFile: "Enviar arquivo…",
|
||||
pluginFieldSelectDirectory: "Escolher pasta…",
|
||||
pluginSshKeyPickerEmpty: "Nenhuma chave encontrada",
|
||||
pluginSshKeyEncrypted: "Criptografada",
|
||||
objectCacheRefreshFailed: "Falha ao atualizar o cache de objetos: {message}",
|
||||
|
||||
@@ -1185,6 +1185,13 @@ export default withEnglishFallback({
|
||||
sshInteractivePlaceholder: "Doğrulama kodunu girin",
|
||||
sshInteractiveSubmit: "Doğrula",
|
||||
sshInteractiveCancel: "İptal",
|
||||
pluginFieldSelectFile: "Dosya seç…",
|
||||
pluginFieldUploadFile: "Dosya yükle…",
|
||||
pluginFieldSelectDirectory: "Klasör seç…",
|
||||
sshPluginInputTitle: "Girdi gerekli",
|
||||
sshPluginInputMessage: "Devam etmek için {source} bir girdi bekliyor.",
|
||||
sshPluginInputMessageNoSource: "Devam etmek için bir eklenti girdi bekliyor.",
|
||||
sshPluginInputSubmit: "Gönder",
|
||||
sshHostKeyNoticeChanged: "{host}:{port} için sunucu anahtarı değişti — bu, ortadaki adam saldırısına işaret edebilir. Yalnızca bu değişikliği bekliyorsanız eski kaydı silip yeniden bağlanın.",
|
||||
sshHostKeyNoticeRejected: "{host}:{port} için sunucu anahtarını reddettiniz; bağlantı iptal edildi ve hiçbir kimlik bilgisi gönderilmedi.",
|
||||
sshHostKeyNoticeLearnFailed: "{host}:{port} için sunucu anahtarı kaydedilemedi — sunucuya yalnızca bu oturum için güvenilecek ve bir sonraki seferde yeniden doğrulamanız istenecek.",
|
||||
|
||||
@@ -1061,6 +1061,9 @@ export default withEnglishFallback({
|
||||
pluginProviderUnavailable: "此插件连接提供者不可用,请重新安装或启用插件后再试。",
|
||||
pluginRequiredField: "请填写{field}",
|
||||
pluginSshKeyPickerPlaceholder: "选择本机 SSH 密钥…",
|
||||
pluginFieldSelectFile: "选择文件…",
|
||||
pluginFieldUploadFile: "上传文件…",
|
||||
pluginFieldSelectDirectory: "选择文件夹…",
|
||||
pluginSshKeyPickerEmpty: "未发现密钥",
|
||||
pluginSshKeyEncrypted: "已加密",
|
||||
jdbcConnection: "JDBC 连接",
|
||||
@@ -1246,6 +1249,10 @@ export default withEnglishFallback({
|
||||
sshInteractivePlaceholder: "输入验证码",
|
||||
sshInteractiveSubmit: "验证",
|
||||
sshInteractiveCancel: "取消",
|
||||
sshPluginInputTitle: "需要输入",
|
||||
sshPluginInputMessage: "{source} 需要你提供输入才能继续。",
|
||||
sshPluginInputMessageNoSource: "插件需要你提供输入才能继续。",
|
||||
sshPluginInputSubmit: "提交",
|
||||
sshHostKeyNoticeChanged: "主机 {host}:{port} 的密钥已变更,可能存在中间人攻击。如确属预期,请删除旧记录后重新连接。",
|
||||
sshHostKeyNoticeRejected: "你已拒绝 {host}:{port} 的主机密钥验证,连接已中止,凭据未发送。",
|
||||
sshHostKeyNoticeLearnFailed: "无法保存 {host}:{port} 的主机密钥 — 该主机仅在本次会话内受信任,下次连接仍需重新确认。",
|
||||
|
||||
@@ -946,6 +946,9 @@ export default withEnglishFallback({
|
||||
pluginProviderUnavailable: "此外掛連線提供者不可用,請重新安裝或啟用外掛後再試。",
|
||||
pluginRequiredField: "請填寫{field}",
|
||||
pluginSshKeyPickerPlaceholder: "選擇本機 SSH 金鑰…",
|
||||
pluginFieldSelectFile: "選擇檔案…",
|
||||
pluginFieldUploadFile: "上傳檔案…",
|
||||
pluginFieldSelectDirectory: "選擇資料夾…",
|
||||
pluginSshKeyPickerEmpty: "未發現金鑰",
|
||||
pluginSshKeyEncrypted: "已加密",
|
||||
jdbcConnection: "JDBC 連線",
|
||||
@@ -1302,6 +1305,10 @@ export default withEnglishFallback({
|
||||
sshInteractivePlaceholder: "輸入驗證碼",
|
||||
sshInteractiveSubmit: "驗證",
|
||||
sshInteractiveCancel: "取消",
|
||||
sshPluginInputTitle: "需要輸入",
|
||||
sshPluginInputMessage: "{source} 需要你提供輸入才能繼續。",
|
||||
sshPluginInputMessageNoSource: "外掛需要你提供輸入才能繼續。",
|
||||
sshPluginInputSubmit: "提交",
|
||||
sshHostKeyNoticeChanged: "主機 {host}:{port} 的金鑰已變更,可能存在中間人攻擊。如確屬預期,請刪除舊記錄後重新連線。",
|
||||
sshHostKeyNoticeRejected: "您已拒絕 {host}:{port} 的主機金鑰驗證,連線已中止,憑證未發送。",
|
||||
sshHostKeyNoticeGeneric: "SSH 主機 {host}:{port} 的金鑰驗證失敗。",
|
||||
|
||||
@@ -46,7 +46,7 @@ export function pluginConnectionNeedsPasswordPrompt(fields: readonly PluginFormF
|
||||
if (!passwordFields.length) return false;
|
||||
const readValue = (key: string): PluginFormFieldValue => {
|
||||
const stored = isRecord(externalConfig) ? (externalConfig[key] as PluginFormFieldValue) : undefined;
|
||||
return stored !== undefined ? stored : fields.find((candidate) => candidate.key === key)?.default;
|
||||
return stored !== undefined ? stored : (fields.find((candidate) => candidate.key === key)?.default ?? undefined);
|
||||
};
|
||||
return passwordFields.some((field) => pluginFieldIsRequired(field, readValue));
|
||||
}
|
||||
|
||||
+86
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { InstalledPlugin, PluginConnectionProviderContribution } from "@/types/database";
|
||||
import type { InstalledPlugin, PluginConnectionProviderContribution, PluginFormFieldValue } from "@/types/database";
|
||||
import { buildPluginConnectionConfig, createFrontendPluginRegistry, initialPluginFormValues, parsePluginConnectionProviderOptionValue, pluginConnectionActionsForDialog, pluginConnectionFormValues, pluginConnectionProviderIcon, pluginConnectionProviderOptionValue } from "./frontendPlugin";
|
||||
|
||||
function installedPlugin(id: string, contributions: InstalledPlugin["manifest"]["contributions"] = []): InstalledPlugin {
|
||||
@@ -324,6 +324,91 @@ describe("FrontendPluginRegistry", () => {
|
||||
expect(values).not.toHaveProperty("host");
|
||||
});
|
||||
|
||||
it("treats a host-serialized null default as unset instead of a value", () => {
|
||||
// Hosts before the manifest serialization fix sent `"default": null` for
|
||||
// every field that declares no default, so an untouched password field
|
||||
// turned into the four-character string "null" once it was saved.
|
||||
const provider = connectionProvider({
|
||||
fields: [
|
||||
{ key: "sudo_password", label: "Sudo password", type: "password", binding: "secret", default: null },
|
||||
{ key: "mode", label: "Mode", type: "text", binding: "config", default: null },
|
||||
{ key: "read_only", label: "Read only", type: "boolean", binding: "config", default: false },
|
||||
],
|
||||
});
|
||||
|
||||
expect(initialPluginFormValues(provider)).toEqual({ read_only: false });
|
||||
|
||||
const config = buildPluginConnectionConfig("io.dbx.ssh", provider, {
|
||||
sudo_password: null,
|
||||
mode: null,
|
||||
read_only: false,
|
||||
} as unknown as Record<string, string>);
|
||||
|
||||
expect(config.connection_secrets?.sudo_password).toBeUndefined();
|
||||
expect(config.external_config).toEqual({ read_only: false });
|
||||
});
|
||||
|
||||
it("preserves opaque 'null' credentials when reopening and saving connections", () => {
|
||||
const provider = connectionProvider({
|
||||
fields: [
|
||||
{ key: "sudo_password", label: "Sudo password", type: "password", binding: "secret" },
|
||||
{ key: "totp_secret", label: "TOTP secret", type: "textarea", binding: "secret" },
|
||||
{ key: "sudo_source", label: "Sudo source", type: "text", binding: "config" },
|
||||
],
|
||||
});
|
||||
const existing = buildPluginConnectionConfig("io.dbx.ssh", provider, {});
|
||||
existing.connection_secrets = { sudo_password: "null", totp_secret: "JBSWY3DPEHPK3PXP" };
|
||||
existing.external_config = { sudo_source: "custom", stale: null };
|
||||
|
||||
const values = pluginConnectionFormValues(provider, existing);
|
||||
|
||||
expect(values.sudo_password).toBe("null");
|
||||
expect(values.totp_secret).toBe("JBSWY3DPEHPK3PXP");
|
||||
expect(values.stale).toBeUndefined();
|
||||
expect(values.sudo_source).toBe("custom");
|
||||
|
||||
const saved = buildPluginConnectionConfig("io.dbx.ssh", provider, values, existing);
|
||||
expect(saved.connection_secrets?.sudo_password).toBe("null");
|
||||
expect(saved.connection_secrets?.totp_secret).toBe("JBSWY3DPEHPK3PXP");
|
||||
});
|
||||
|
||||
it.each([undefined, "secret", "password"] as const)("round-trips explicit 'null' credentials with binding %s", (binding) => {
|
||||
const provider = connectionProvider({ fields: [{ key: "credential", label: "Credential", type: "password", binding }] });
|
||||
const saved = buildPluginConnectionConfig("example.plugin", provider, { credential: "null" });
|
||||
expect(binding === "password" ? saved.password : saved.connection_secrets?.credential).toBe("null");
|
||||
expect(pluginConnectionFormValues(provider, saved)).toEqual({ credential: "null" });
|
||||
const reopened = buildPluginConnectionConfig("example.plugin", provider, pluginConnectionFormValues(provider, saved), saved);
|
||||
expect(pluginConnectionFormValues(provider, reopened)).toEqual({ credential: "null" });
|
||||
});
|
||||
|
||||
it("migrates an opaque 'null' credential from config to secret storage without losing it", () => {
|
||||
const provider = connectionProvider({ fields: [{ key: "credential", label: "Credential", type: "password" }] });
|
||||
const existing = buildPluginConnectionConfig("example.plugin", provider, {});
|
||||
existing.external_config = { credential: "null" };
|
||||
const values = pluginConnectionFormValues(provider, existing);
|
||||
expect(values).toEqual({ credential: "null" });
|
||||
const saved = buildPluginConnectionConfig("example.plugin", provider, values, existing);
|
||||
expect(saved.connection_secrets).toEqual({ credential: "null" });
|
||||
expect(saved.external_config).toEqual({});
|
||||
});
|
||||
|
||||
it.each([null, undefined, ""])("keeps an unset credential %s absent without a default", (credential) => {
|
||||
const provider = connectionProvider({ fields: [{ key: "credential", label: "Credential", type: "password" }] });
|
||||
const existing = buildPluginConnectionConfig("example.plugin", provider, {});
|
||||
existing.connection_secrets = { credential: "old-secret" };
|
||||
const saved = buildPluginConnectionConfig("example.plugin", provider, { credential } as unknown as Record<string, PluginFormFieldValue>, existing);
|
||||
expect(saved.connection_secrets).toEqual({});
|
||||
expect(pluginConnectionFormValues(provider, saved)).toEqual({});
|
||||
});
|
||||
|
||||
it("uses an explicit string default only for missing input, not a cleared input", () => {
|
||||
const provider = connectionProvider({ fields: [{ key: "credential", label: "Credential", type: "password", default: "null" }] });
|
||||
expect(initialPluginFormValues(provider)).toEqual({ credential: "null" });
|
||||
expect(buildPluginConnectionConfig("example.plugin", provider, {}).connection_secrets).toEqual({ credential: "null" });
|
||||
expect(buildPluginConnectionConfig("example.plugin", provider, { credential: null } as unknown as Record<string, PluginFormFieldValue>).connection_secrets).toEqual({});
|
||||
expect(buildPluginConnectionConfig("example.plugin", provider, { credential: "" }).connection_secrets).toEqual({});
|
||||
});
|
||||
|
||||
it("maps provider fields into common, config, and secret storage", () => {
|
||||
const provider: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
|
||||
@@ -115,7 +115,9 @@ export function pluginConnectionActionsForDialog(contribution: PluginConnectionP
|
||||
}
|
||||
|
||||
export function initialPluginFormValues(contribution: PluginConnectionProviderContribution): Record<string, PluginFormFieldValue> {
|
||||
return Object.fromEntries(contribution.fields.filter((field) => field.default !== undefined).map((field) => [field.key, field.default])) as Record<string, PluginFormFieldValue>;
|
||||
// `null` means "no default" (older hosts serialized the absent case that way),
|
||||
// so it must not seed the form with a value the user never typed.
|
||||
return Object.fromEntries(contribution.fields.filter((field) => field.default !== undefined && field.default !== null).map((field) => [field.key, field.default])) as Record<string, PluginFormFieldValue>;
|
||||
}
|
||||
|
||||
export function pluginConnectionFormValues(contribution: PluginConnectionProviderContribution, config?: ConnectionConfig): Record<string, PluginFormFieldValue> {
|
||||
@@ -125,22 +127,9 @@ export function pluginConnectionFormValues(contribution: PluginConnectionProvide
|
||||
const secrets = config.connection_secrets || {};
|
||||
for (const field of contribution.fields) {
|
||||
const binding = effectiveFieldBinding(field);
|
||||
const secret = secrets[field.key] ?? externalConfig[field.key];
|
||||
const value =
|
||||
binding === "name"
|
||||
? config.name
|
||||
: binding === "host"
|
||||
? config.host
|
||||
: binding === "port"
|
||||
? config.port
|
||||
: binding === "username"
|
||||
? config.username
|
||||
: binding === "password"
|
||||
? config.password
|
||||
: binding === "database"
|
||||
? config.database
|
||||
: binding === "secret"
|
||||
? (secrets[field.key] ?? externalConfig[field.key])
|
||||
: externalConfig[field.key];
|
||||
binding === "name" ? config.name : binding === "host" ? config.host : binding === "port" ? config.port : binding === "username" ? config.username : binding === "password" ? config.password : binding === "database" ? config.database : binding === "secret" ? secret : externalConfig[field.key];
|
||||
// Port 0 is the stored representation of an optional, automatic port.
|
||||
// Keep that input empty on reopen so its protocol-default hint remains visible.
|
||||
if (binding === "port" && value === 0 && field.default === undefined) delete values[field.key];
|
||||
@@ -180,7 +169,11 @@ export function buildPluginConnectionConfig(pluginId: string, contribution: Plug
|
||||
production_databases: existing?.production_databases || [],
|
||||
};
|
||||
for (const field of contribution.fields) {
|
||||
const value = values[field.key] ?? field.default;
|
||||
// A `null` coming from the form (or from a host that hydrated absent
|
||||
// defaults as `null`) means "unset": fall back to the declared default and
|
||||
// otherwise clear the stored value instead of persisting a null.
|
||||
const raw = values[field.key];
|
||||
const value = raw === null ? undefined : (raw ?? field.default ?? undefined);
|
||||
const binding = effectiveFieldBinding(field);
|
||||
if (binding === "config") {
|
||||
if (value === undefined) delete externalConfig[field.key];
|
||||
|
||||
+173
-9
@@ -1,22 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pluginFieldConditionMatches, pluginFieldIsRequired, pluginFieldIsVisible } from "./pluginFieldConditions";
|
||||
import type { PluginFormField, PluginFormFieldValue } from "@/types/database";
|
||||
import { pluginConditionValueText, pluginFieldConditionMatches, pluginFieldConditionReferencedFields, pluginFieldIsRequired, pluginFieldIsVisible } from "./pluginFieldConditions";
|
||||
import type { PluginFieldCondition, PluginFormField, PluginFormFieldValue } from "@/types/database";
|
||||
|
||||
function field(overrides: Partial<PluginFormField>): PluginFormField {
|
||||
return { key: "demo", label: "Demo", type: "text", ...overrides } as PluginFormField;
|
||||
}
|
||||
|
||||
const readValue = (key: string) => (key === "mode" ? "custom" : undefined);
|
||||
const readValue = (key: string): PluginFormFieldValue => (key === "mode" ? "custom" : undefined);
|
||||
/** Reads a fixed map, so a condition can be evaluated against known values. */
|
||||
function reader(values: Record<string, PluginFormFieldValue>): (key: string) => PluginFormFieldValue {
|
||||
return (key) => values[key];
|
||||
}
|
||||
|
||||
describe("pluginFieldConditionMatches", () => {
|
||||
it("matches a missing condition and a listed value", () => {
|
||||
expect(pluginFieldConditionMatches(undefined, undefined)).toBe(true);
|
||||
expect(pluginFieldConditionMatches({ field: "mode", one_of: ["custom"] }, "custom")).toBe(true);
|
||||
expect(pluginFieldConditionMatches(undefined, () => undefined)).toBe(true);
|
||||
expect(pluginFieldConditionMatches({ field: "mode", one_of: ["custom"] }, reader({ mode: "custom" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an unlisted or empty value", () => {
|
||||
expect(pluginFieldConditionMatches({ field: "mode", one_of: ["custom"] }, "direct")).toBe(false);
|
||||
expect(pluginFieldConditionMatches({ field: "mode", one_of: ["custom"] }, undefined)).toBe(false);
|
||||
expect(pluginFieldConditionMatches({ field: "mode", one_of: ["custom"] }, reader({ mode: "direct" }))).toBe(false);
|
||||
expect(pluginFieldConditionMatches({ field: "mode", one_of: ["custom"] }, reader({}))).toBe(false);
|
||||
expect(pluginFieldConditionMatches({ field: "mode", one_of: ["custom"] }, reader({ mode: " " }))).toBe(false);
|
||||
});
|
||||
|
||||
it("compares boolean and number literals by canonical string form", () => {
|
||||
// `read_only = false` is the composite-condition driver the SSH plugin needs.
|
||||
const readOnly = { field: "read_only", one_of: [false] } as PluginFieldCondition;
|
||||
expect(pluginFieldConditionMatches(readOnly, reader({ read_only: false }))).toBe(true);
|
||||
expect(pluginFieldConditionMatches(readOnly, reader({ read_only: true }))).toBe(false);
|
||||
expect(pluginFieldConditionMatches({ field: "read_only", one_of: ["false"] }, reader({ read_only: false }))).toBe(true);
|
||||
expect(pluginFieldConditionMatches({ field: "port", one_of: [2222] }, reader({ port: 2222 }))).toBe(true);
|
||||
expect(pluginFieldConditionMatches({ field: "port", one_of: [2222.0] }, reader({ port: 2222 }))).toBe(true);
|
||||
expect(pluginConditionValueText(false)).toBe("false");
|
||||
expect(pluginConditionValueText(22)).toBe("22");
|
||||
});
|
||||
|
||||
it("composes clauses with all_of, any_of and not", () => {
|
||||
const allOf = {
|
||||
all_of: [
|
||||
{ field: "sudo_source", one_of: ["custom"] },
|
||||
{ field: "read_only", one_of: [false] },
|
||||
],
|
||||
} as PluginFieldCondition;
|
||||
expect(pluginFieldConditionMatches(allOf, reader({ sudo_source: "custom", read_only: false }))).toBe(true);
|
||||
expect(pluginFieldConditionMatches(allOf, reader({ sudo_source: "custom", read_only: true }))).toBe(false);
|
||||
expect(pluginFieldConditionMatches(allOf, reader({ sudo_source: "default", read_only: false }))).toBe(false);
|
||||
|
||||
const anyOf = {
|
||||
any_of: [
|
||||
{ field: "authentication", one_of: ["password"] },
|
||||
{ field: "authentication", one_of: ["keyboard-interactive"] },
|
||||
],
|
||||
} as PluginFieldCondition;
|
||||
expect(pluginFieldConditionMatches(anyOf, reader({ authentication: "keyboard-interactive" }))).toBe(true);
|
||||
expect(pluginFieldConditionMatches(anyOf, reader({ authentication: "agent" }))).toBe(false);
|
||||
|
||||
const not = { not: { field: "read_only", one_of: [true] } } as PluginFieldCondition;
|
||||
expect(pluginFieldConditionMatches(not, reader({ read_only: false }))).toBe(true);
|
||||
expect(pluginFieldConditionMatches(not, reader({ read_only: true }))).toBe(false);
|
||||
|
||||
const nested = { all_of: [anyOf, not, { any_of: [{ all_of: [{ field: "read_only", one_of: [false] }] }] }] } as PluginFieldCondition;
|
||||
expect(pluginFieldConditionMatches(nested, reader({ authentication: "password", read_only: false }))).toBe(true);
|
||||
expect(pluginFieldConditionMatches(nested, reader({ authentication: "password", read_only: true }))).toBe(false);
|
||||
});
|
||||
|
||||
it("lists every referenced field of a nested expression", () => {
|
||||
expect(
|
||||
pluginFieldConditionReferencedFields({
|
||||
all_of: [
|
||||
{
|
||||
any_of: [
|
||||
{ field: "a", one_of: ["1"] },
|
||||
{ field: "b", one_of: ["1"] },
|
||||
],
|
||||
},
|
||||
{ not: { field: "c", one_of: ["1"] } },
|
||||
],
|
||||
}),
|
||||
).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +97,19 @@ describe("pluginFieldIsRequired", () => {
|
||||
expect(pluginFieldIsRequired(conditional, () => "direct")).toBe(false);
|
||||
expect(pluginFieldIsRequired(conditional, () => undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires a field only while every clause of an all_of required_when matches", () => {
|
||||
const conditional = field({
|
||||
required_when: {
|
||||
all_of: [
|
||||
{ field: "sudo_source", one_of: ["custom"] },
|
||||
{ field: "read_only", one_of: [false] },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(pluginFieldIsRequired(conditional, reader({ sudo_source: "custom", read_only: false }))).toBe(true);
|
||||
expect(pluginFieldIsRequired(conditional, reader({ sudo_source: "custom", read_only: true }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pluginFieldIsVisible", () => {
|
||||
@@ -45,6 +120,62 @@ describe("pluginFieldIsVisible", () => {
|
||||
expect(pluginFieldIsVisible(conditional, () => "direct")).toBe(false);
|
||||
});
|
||||
|
||||
it("renders a field only while every clause of an all_of visible_when matches", () => {
|
||||
const conditional = field({
|
||||
visible_when: {
|
||||
all_of: [
|
||||
{ field: "sudo_source", one_of: ["custom"] },
|
||||
{ field: "read_only", one_of: [false] },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(pluginFieldIsVisible(conditional, reader({ sudo_source: "custom", read_only: false }))).toBe(true);
|
||||
expect(pluginFieldIsVisible(conditional, reader({ sudo_source: "custom", read_only: true }))).toBe(false);
|
||||
expect(pluginFieldIsVisible(conditional, reader({ read_only: false }))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps an unused any_of branch from hiding a matching field", () => {
|
||||
// `visible_when.any_of` matches through the first clause; the second clause
|
||||
// references a sibling that is itself hidden, and that must not matter.
|
||||
const fields: Record<string, PluginFormField> = {
|
||||
source: field({ key: "source", default: "custom" }),
|
||||
hidden_controller: field({ key: "hidden_controller", default: "on", visible_when: { field: "source", one_of: ["remote"] } }),
|
||||
target: field({
|
||||
key: "target",
|
||||
visible_when: {
|
||||
any_of: [
|
||||
{ field: "source", one_of: ["custom"] },
|
||||
{ field: "hidden_controller", one_of: ["on"] },
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
const read: (key: string) => PluginFormFieldValue = (key) => fields[key]?.default ?? undefined;
|
||||
const resolve: (key: string) => PluginFormField | undefined = (key) => fields[key];
|
||||
|
||||
expect(pluginFieldIsVisible(fields.target, read, resolve)).toBe(true);
|
||||
// ...while a match that *depends* on the hidden controller stays dormant.
|
||||
const onlyHidden = field({
|
||||
key: "only_hidden",
|
||||
visible_when: { any_of: [{ field: "hidden_controller", one_of: ["on"] }] },
|
||||
});
|
||||
expect(pluginFieldIsVisible(onlyHidden, read, resolve)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a hidden operand of `not` as dormant instead of lighting the field up", () => {
|
||||
const fields: Record<string, PluginFormField> = {
|
||||
mode: field({ key: "mode", default: "remote" }),
|
||||
read_only: field({ key: "read_only", default: false, visible_when: { field: "mode", one_of: ["local"] } }),
|
||||
writable_options: field({ key: "writable_options", visible_when: { not: { field: "read_only", one_of: [true] } } }),
|
||||
};
|
||||
const read: (key: string) => PluginFormFieldValue = (key) => fields[key]?.default ?? undefined;
|
||||
const resolve: (key: string) => PluginFormField | undefined = (key) => fields[key];
|
||||
|
||||
expect(pluginFieldIsVisible(fields.writable_options, read, resolve)).toBe(false);
|
||||
const localRead = reader({ mode: "local", read_only: false });
|
||||
expect(pluginFieldIsVisible(fields.writable_options, localRead, resolve)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a condition dormant while its referenced sibling is hidden, even when the sibling default would match", () => {
|
||||
// kafka-shaped chain: sasl_mechanism (default PLAIN, hidden) →
|
||||
// oauth_token_source (visible only for OAUTHBEARER, defaults msk_iam) →
|
||||
@@ -56,16 +187,49 @@ describe("pluginFieldIsVisible", () => {
|
||||
oauth_token_source: field({ key: "oauth_token_source", default: "msk_iam", visible_when: { field: "sasl_mechanism", one_of: ["OAUTHBEARER"] } }),
|
||||
msk_region: field({ key: "msk_region", visible_when: { field: "oauth_token_source", one_of: ["msk_iam"] } }),
|
||||
};
|
||||
const read: (key: string) => PluginFormFieldValue = (key) => fields[key]?.default;
|
||||
const read: (key: string) => PluginFormFieldValue = (key) => fields[key]?.default ?? undefined;
|
||||
const resolve: (key: string) => PluginFormField | undefined = (key) => fields[key];
|
||||
|
||||
expect(pluginFieldIsVisible(fields.msk_region, read, resolve)).toBe(false);
|
||||
// Turning OAUTHBEARER on makes the whole chain visible again.
|
||||
const readOauth: (key: string) => PluginFormFieldValue = (key) => (key === "sasl_mechanism" ? "OAUTHBEARER" : key === "security_protocol" ? "SASL_SSL" : fields[key]?.default);
|
||||
const readOauth: (key: string) => PluginFormFieldValue = (key) => (key === "sasl_mechanism" ? "OAUTHBEARER" : key === "security_protocol" ? "SASL_SSL" : (fields[key]?.default ?? undefined));
|
||||
expect(pluginFieldIsVisible(fields.oauth_token_source, readOauth, resolve)).toBe(true);
|
||||
expect(pluginFieldIsVisible(fields.msk_region, readOauth, resolve)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps sibling branches path-local with reverse order %s", (reverse) => {
|
||||
const fields: Record<string, PluginFormField> = {
|
||||
mode: field({ key: "mode" }),
|
||||
auth: field({ key: "auth", visible_when: { field: "mode", one_of: ["enabled"] } }),
|
||||
};
|
||||
const clause: PluginFieldCondition = { field: "auth", one_of: ["password"] };
|
||||
const branches: PluginFieldCondition[] = [{ all_of: [clause, { field: "flag", one_of: [true] }] }, { all_of: [{ field: "flag", one_of: [false] }, clause] }];
|
||||
if (reverse) branches.reverse();
|
||||
const resolve = (key: string) => fields[key];
|
||||
for (const flag of [false, true]) {
|
||||
const hidden = reader({ mode: "disabled", auth: "password", flag });
|
||||
const visible = reader({ mode: "enabled", auth: "password", flag });
|
||||
for (const condition of [clause, { any_of: [clause, clause] }, { any_of: branches }, { all_of: [clause, clause] }] satisfies PluginFieldCondition[]) {
|
||||
const target = field({ key: "target", visible_when: condition });
|
||||
expect(pluginFieldIsVisible(target, hidden, resolve)).toBe(false);
|
||||
expect(pluginFieldIsVisible(target, visible, resolve)).toBe(true);
|
||||
}
|
||||
const negated = field({ visible_when: { not: { any_of: [clause, clause] } } });
|
||||
expect(pluginFieldIsVisible(negated, hidden, resolve)).toBe(false);
|
||||
expect(pluginFieldIsVisible(negated, reader({ mode: "enabled", auth: "token", flag }), resolve)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves true mutual-cycle compatibility without bypassing value checks", () => {
|
||||
const fields: Record<string, PluginFormField> = {
|
||||
first: field({ key: "first", visible_when: { field: "second", one_of: ["on"] } }),
|
||||
second: field({ key: "second", visible_when: { field: "first", one_of: ["on"] } }),
|
||||
};
|
||||
const resolve = (key: string) => fields[key];
|
||||
expect(pluginFieldIsVisible(fields.first, reader({ first: "on", second: "on" }), resolve)).toBe(true);
|
||||
expect(pluginFieldIsVisible(fields.first, reader({ first: "off", second: "on" }), resolve)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores unknown condition fields and survives self-referencing cycles", () => {
|
||||
const unknown = field({ visible_when: { field: "missing", one_of: ["x"] } });
|
||||
expect(
|
||||
|
||||
@@ -1,14 +1,67 @@
|
||||
import type { PluginFieldCondition, PluginFormField, PluginFormFieldValue } from "@/types/database";
|
||||
|
||||
/**
|
||||
* Evaluates a plugin manifest field condition (`visible_when` / `required_when`,
|
||||
* Host API 1.1). A condition matches when the current value of the referenced
|
||||
* sibling field is listed in `one_of`. Missing conditions always match.
|
||||
* Canonical string form of a form value, matching the Rust host
|
||||
* (`condition_value_text` in `crates/dbx-core/src/plugins/manifest.rs`).
|
||||
* `false` renders as `"false"` and `22.0` as `"22"`.
|
||||
*/
|
||||
export function pluginFieldConditionMatches(condition: PluginFieldCondition | undefined, value: PluginFormFieldValue): boolean {
|
||||
if (!condition) return true;
|
||||
export function pluginConditionValueText(value: PluginFormFieldValue): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** Reads the current value of a sibling form field by key. */
|
||||
export type PluginFieldValueReader = (key: string) => PluginFormFieldValue;
|
||||
|
||||
/** A leaf clause matches when the sibling value equals one of its literals. */
|
||||
function conditionClauseMatches(clause: { field: string; one_of: readonly (string | number | boolean)[] }, value: PluginFormFieldValue): boolean {
|
||||
if (value === undefined || value === null || (typeof value === "string" && value.trim().length === 0)) return false;
|
||||
return condition.one_of.includes(String(value));
|
||||
const text = pluginConditionValueText(value);
|
||||
return clause.one_of.some((literal) => pluginConditionValueText(literal) === text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates a plugin manifest field condition (`visible_when` / `required_when`,
|
||||
* Host API 1.1+). A leaf matches when the current value of the referenced
|
||||
* sibling field is listed in `one_of`; `all_of` / `any_of` / `not` compose
|
||||
* clauses, so a manifest can express `sudo_source = custom AND read_only =
|
||||
* false`. Missing conditions always match; a missing or empty sibling value
|
||||
* never matches.
|
||||
*/
|
||||
export function pluginFieldConditionMatches(condition: PluginFieldCondition | undefined, readValue: PluginFieldValueReader): boolean {
|
||||
if (!condition) return true;
|
||||
return conditionExpressionMatches(condition, readValue);
|
||||
}
|
||||
|
||||
function conditionExpressionMatches(condition: PluginFieldCondition, readValue: PluginFieldValueReader): boolean {
|
||||
if (isConditionClause(condition)) return conditionClauseMatches(condition, readValue(condition.field));
|
||||
if (isConditionAllOf(condition)) {
|
||||
return condition.all_of.every((child) => conditionExpressionMatches(child, readValue));
|
||||
}
|
||||
if (isConditionAnyOf(condition)) {
|
||||
return condition.any_of.some((child) => conditionExpressionMatches(child, readValue));
|
||||
}
|
||||
return !conditionExpressionMatches(condition.not, readValue);
|
||||
}
|
||||
|
||||
/** Field keys the expression reads, in evaluation order (duplicates kept). */
|
||||
export function pluginFieldConditionReferencedFields(condition: PluginFieldCondition): string[] {
|
||||
if (isConditionClause(condition)) return [condition.field];
|
||||
if (isConditionAllOf(condition)) return condition.all_of.flatMap(pluginFieldConditionReferencedFields);
|
||||
if (isConditionAnyOf(condition)) return condition.any_of.flatMap(pluginFieldConditionReferencedFields);
|
||||
return pluginFieldConditionReferencedFields(condition.not);
|
||||
}
|
||||
|
||||
function isConditionClause(condition: PluginFieldCondition): condition is { field: string; one_of: (string | number | boolean)[] } {
|
||||
return typeof (condition as { field?: unknown }).field === "string";
|
||||
}
|
||||
|
||||
function isConditionAllOf(condition: PluginFieldCondition): condition is { all_of: PluginFieldCondition[] } {
|
||||
return Array.isArray((condition as { all_of?: unknown }).all_of);
|
||||
}
|
||||
|
||||
function isConditionAnyOf(condition: PluginFieldCondition): condition is { any_of: PluginFieldCondition[] } {
|
||||
return Array.isArray((condition as { any_of?: unknown }).any_of);
|
||||
}
|
||||
|
||||
/** Looks a sibling field up by key; returns undefined when the key is unknown. */
|
||||
@@ -28,11 +81,40 @@ export function pluginFieldIsVisible(field: PluginFormField, readValue: (key: st
|
||||
function pluginFieldIsVisibleCached(field: PluginFormField, readValue: (key: string) => PluginFormFieldValue, resolveField: PluginFieldResolver | undefined, seen: Set<string>): boolean {
|
||||
const condition = field.visible_when;
|
||||
if (!condition) return true;
|
||||
if (!pluginFieldConditionMatches(condition, readValue(condition.field))) return false;
|
||||
const target = resolveField?.(condition.field);
|
||||
return conditionExpressionVisible(condition, readValue, resolveField, seen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Visibility-aware evaluation of one condition expression.
|
||||
*
|
||||
* A clause only counts while the sibling it reads is visible *and* its own
|
||||
* `visible_when` chain is satisfied, so a hidden controller's stored default
|
||||
* cannot light a field up. `not` additionally requires every operand to be
|
||||
* visible: an inverted hidden value is not a usable answer.
|
||||
*/
|
||||
function conditionExpressionVisible(condition: PluginFieldCondition, readValue: PluginFieldValueReader, resolveField: PluginFieldResolver | undefined, seen: Set<string>): boolean {
|
||||
if (isConditionClause(condition)) {
|
||||
if (!conditionClauseMatches(condition, readValue(condition.field))) return false;
|
||||
return conditionOperandVisible(condition.field, readValue, resolveField, seen);
|
||||
}
|
||||
if (isConditionAllOf(condition)) {
|
||||
return condition.all_of.every((child) => conditionExpressionVisible(child, readValue, resolveField, seen));
|
||||
}
|
||||
if (isConditionAnyOf(condition)) {
|
||||
return condition.any_of.some((child) => conditionExpressionVisible(child, readValue, resolveField, seen));
|
||||
}
|
||||
const operandsVisible = pluginFieldConditionReferencedFields(condition.not).every((key) => conditionOperandVisible(key, readValue, resolveField, seen));
|
||||
return operandsVisible && !conditionExpressionMatches(condition.not, readValue);
|
||||
}
|
||||
|
||||
/** Whether the sibling behind one clause is itself rendered. Cycles count as visible. */
|
||||
function conditionOperandVisible(key: string, readValue: PluginFieldValueReader, resolveField: PluginFieldResolver | undefined, seen: Set<string>): boolean {
|
||||
const target = resolveField?.(key);
|
||||
if (!target || seen.has(target.key)) return true;
|
||||
seen.add(target.key);
|
||||
return pluginFieldIsVisibleCached(target, readValue, resolveField, seen);
|
||||
const visible = pluginFieldIsVisibleCached(target, readValue, resolveField, seen);
|
||||
seen.delete(target.key);
|
||||
return visible;
|
||||
}
|
||||
|
||||
/** Effective required = static `required` OR a matching `required_when`. */
|
||||
@@ -43,5 +125,5 @@ export function pluginFieldIsRequired(field: PluginFormField, readValue: (key: s
|
||||
// for visibility), so it must not be consulted here — that used to mark
|
||||
// every plugin field required and dead-locked the connection footer.
|
||||
if (!field.required_when) return false;
|
||||
return pluginFieldConditionMatches(field.required_when, readValue(field.required_when.field));
|
||||
return pluginFieldConditionMatches(field.required_when, readValue);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { tauriRuntime, openMock } = vi.hoisted(() => ({ tauriRuntime: { value: false }, openMock: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => tauriRuntime.value }));
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: openMock }));
|
||||
|
||||
import { PLUGIN_PICKER_MAX_BYTES, pickPluginFieldFile } from "./pluginFieldPicker";
|
||||
|
||||
/** Captures the hidden `<input type="file">` the browser path creates. */
|
||||
function stubFileSelection(files: File[]) {
|
||||
const original = document.createElement.bind(document);
|
||||
const created: HTMLInputElement[] = [];
|
||||
const spy = vi.spyOn(document, "createElement").mockImplementation(((tag: string, options?: ElementCreationOptions) => {
|
||||
const element = original(tag, options as never);
|
||||
if (tag === "input") {
|
||||
created.push(element as HTMLInputElement);
|
||||
setTimeout(() => {
|
||||
Object.defineProperty(element, "files", { value: files, configurable: true });
|
||||
element.dispatchEvent(new Event("change"));
|
||||
}, 0);
|
||||
}
|
||||
return element;
|
||||
}) as typeof document.createElement);
|
||||
return { created, restore: () => spy.mockRestore() };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tauriRuntime.value = false;
|
||||
openMock.mockReset();
|
||||
});
|
||||
|
||||
describe("pickPluginFieldFile", () => {
|
||||
it("returns the client path on desktop so the plugin can read the file itself", async () => {
|
||||
tauriRuntime.value = true;
|
||||
openMock.mockResolvedValue("/Users/dev/.ssh/id_rsa");
|
||||
|
||||
const picked = await pickPluginFieldFile({ kind: "file", accept: [".pem", ".key"] });
|
||||
|
||||
expect(picked).toEqual({ path: "/Users/dev/.ssh/id_rsa", name: "id_rsa" });
|
||||
expect(openMock).toHaveBeenCalledWith({
|
||||
directory: false,
|
||||
multiple: false,
|
||||
filters: [{ name: "Files", extensions: ["pem", "key"] }],
|
||||
});
|
||||
});
|
||||
|
||||
it("offers a folder dialog for directory pickers and ignores a dismissal", async () => {
|
||||
tauriRuntime.value = true;
|
||||
openMock.mockResolvedValue(null);
|
||||
|
||||
expect(await pickPluginFieldFile({ kind: "directory" })).toBeNull();
|
||||
expect(openMock).toHaveBeenCalledWith({ directory: true, multiple: false, filters: undefined });
|
||||
});
|
||||
|
||||
it("reads the file content on browser hosts, which cannot hand over a path", async () => {
|
||||
const files = [new File(["-----BEGIN PRIVATE KEY-----\nkey\n"], "id_rsa", { type: "text/plain" })];
|
||||
const selection = stubFileSelection(files);
|
||||
try {
|
||||
const picked = await pickPluginFieldFile({ kind: "file", accept: [".pem", "text/plain"] });
|
||||
|
||||
expect(picked).toEqual({ content: "-----BEGIN PRIVATE KEY-----\nkey\n", name: "id_rsa" });
|
||||
expect(selection.created[0]?.accept).toBe(".pem,text/plain");
|
||||
expect(selection.created[0]?.multiple).toBe(false);
|
||||
} finally {
|
||||
selection.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("has no browser equivalent for a folder picker", async () => {
|
||||
expect(await pickPluginFieldFile({ kind: "directory" })).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an oversized upload instead of loading it", async () => {
|
||||
const oversized = new File(["x"], "big.key", { type: "text/plain" });
|
||||
Object.defineProperty(oversized, "size", { value: PLUGIN_PICKER_MAX_BYTES + 1 });
|
||||
const selection = stubFileSelection([oversized]);
|
||||
try {
|
||||
await expect(pickPluginFieldFile({ kind: "file" })).rejects.toThrow(/larger than 1024 KiB/);
|
||||
} finally {
|
||||
selection.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { PluginFormFieldPicker } from "@/types/database";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
|
||||
/** Uploads larger than this are rejected instead of being held in memory. */
|
||||
export const PLUGIN_PICKER_MAX_BYTES = 1_048_576;
|
||||
|
||||
export interface PluginPickedFile {
|
||||
/** Absolute path of the selected file; desktop hosts only. */
|
||||
path?: string;
|
||||
/** File content; browser hosts only (they cannot produce a usable path). */
|
||||
content?: string;
|
||||
/** File name, for messages. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the manifest's picker action for one field.
|
||||
*
|
||||
* Desktop hosts (Tauri) open the native dialog and return the absolute path, so
|
||||
* the plugin — which runs on the same machine — can read the file itself.
|
||||
* Browser hosts have no client filesystem: they read the selected file and hand
|
||||
* back its content, which the form stores in the declared `content_field`
|
||||
* (the browser build cannot hand the server a path to a client file).
|
||||
*
|
||||
* Returns `null` when the user cancels.
|
||||
*/
|
||||
export async function pickPluginFieldFile(picker: PluginFormFieldPicker): Promise<PluginPickedFile | null> {
|
||||
const accepted = (picker.accept || []).slice();
|
||||
if (isTauriRuntime()) {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const extensions = accepted.filter((entry) => entry.startsWith(".")).map((entry) => entry.replace(/^\./, ""));
|
||||
const selected = await open({
|
||||
directory: picker.kind === "directory",
|
||||
multiple: false,
|
||||
filters: extensions.length ? [{ name: picker.kind === "directory" ? "Folders" : "Files", extensions }] : undefined,
|
||||
});
|
||||
if (typeof selected !== "string" || !selected) return null;
|
||||
return { path: selected, name: selected.replaceAll("\\", "/").split("/").pop() || selected };
|
||||
}
|
||||
|
||||
if (picker.kind === "directory") return null;
|
||||
const file = await selectBrowserFile(accepted);
|
||||
if (!file) return null;
|
||||
if (file.size > PLUGIN_PICKER_MAX_BYTES) {
|
||||
throw new Error(`File is larger than ${Math.round(PLUGIN_PICKER_MAX_BYTES / 1024)} KiB`);
|
||||
}
|
||||
return { content: await file.text(), name: file.name };
|
||||
}
|
||||
|
||||
function selectBrowserFile(accept: string[]): Promise<File | null> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = false;
|
||||
if (accept.length) input.accept = accept.join(",");
|
||||
// Chrome fires `cancel` when the dialog is dismissed; older engines only
|
||||
// fire `change` without files, which the handler below already treats as a
|
||||
// cancellation.
|
||||
input.addEventListener("cancel", () => resolve(null), { once: true });
|
||||
input.addEventListener(
|
||||
"change",
|
||||
() => {
|
||||
resolve(input.files?.[0] ?? null);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
@@ -295,13 +295,53 @@ export interface PluginFormFieldOption {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PluginFieldCondition {
|
||||
/** Key of another plugin form field whose current value drives the condition. */
|
||||
/** A literal a `one_of` clause may list; compared by canonical string form. */
|
||||
export type PluginFieldConditionLiteral = string | number | boolean;
|
||||
|
||||
/** Legacy single-field clause: `{ field, one_of }`. */
|
||||
export interface PluginFieldConditionClause {
|
||||
/** Key of another plugin form field whose current value drives the clause. */
|
||||
field: string;
|
||||
/** The condition matches when the referenced field's value is in this list. */
|
||||
one_of: string[];
|
||||
/** The clause matches when the referenced field's value is in this list. */
|
||||
one_of: PluginFieldConditionLiteral[];
|
||||
}
|
||||
|
||||
/** Every nested condition must match. */
|
||||
export interface PluginFieldConditionAllOf {
|
||||
all_of: PluginFieldCondition[];
|
||||
}
|
||||
|
||||
/** At least one nested condition must match. */
|
||||
export interface PluginFieldConditionAnyOf {
|
||||
any_of: PluginFieldCondition[];
|
||||
}
|
||||
|
||||
/** Inverts the nested condition. */
|
||||
export interface PluginFieldConditionNot {
|
||||
not: PluginFieldCondition;
|
||||
}
|
||||
|
||||
/** Host API 1.1: local-file action on a plugin connection field. */
|
||||
export interface PluginFormFieldPicker {
|
||||
/** `directory` is desktop-only. */
|
||||
kind: "file" | "directory";
|
||||
/** Extensions (`.pem`) or MIME types (`text/plain`) offered by the picker. */
|
||||
accept?: string[];
|
||||
/**
|
||||
* Declared sibling field that receives the file content on hosts without a
|
||||
* client filesystem (the browser build). Desktop hosts store the chosen path
|
||||
* in the declaring field and clear this one instead.
|
||||
*/
|
||||
content_field?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `visible_when` / `required_when` expression. The legacy `{ field, one_of }`
|
||||
* clause keeps its exact meaning; `all_of` / `any_of` / `not` compose clauses
|
||||
* (e.g. `sudo_source = custom AND read_only = false`).
|
||||
*/
|
||||
export type PluginFieldCondition = PluginFieldConditionClause | PluginFieldConditionAllOf | PluginFieldConditionAnyOf | PluginFieldConditionNot;
|
||||
|
||||
export interface PluginFormField {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -309,11 +349,15 @@ export interface PluginFormField {
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
default?: PluginFormFieldValue;
|
||||
/** Declared default. Hosts older than the manifest serialization fix send
|
||||
* `null` for "no default", which the form treats as unset. */
|
||||
default?: PluginFormFieldValue | null;
|
||||
options?: PluginFormFieldOption[];
|
||||
/** Plugin method returning `{ options: [{ value, label }] }` for dynamic
|
||||
* select rendering; falls back to the declared type when unavailable. */
|
||||
options_action?: string;
|
||||
/** Host API 1.1: offer a local-file action on this field. */
|
||||
picker?: PluginFormFieldPicker;
|
||||
binding?: PluginFormFieldBinding;
|
||||
visible_when?: PluginFieldCondition;
|
||||
required_when?: PluginFieldCondition;
|
||||
|
||||
Reference in new issue
Block a user