mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own (#1632)
* fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own * fix(sync): switch off an untrusted mirrored plugin when the server updates it * fix: require local consent for mirrored plugin activation * docs: describe consent fields on the plugin state endpoint
This commit is contained in:
1 parent
9b0af07607
commit
b3b08b4ff2
17 files changed
+515
-36
No files matched your search
@@ -263,6 +263,8 @@ export interface PluginSummary {
|
||||
/** Environment variables the plugin reads. Admins only. */
|
||||
env?: PluginEnvVar[];
|
||||
signedBy?: string | null;
|
||||
/** A linked server decides whether it runs here. Admins only. */
|
||||
managedByServer?: boolean;
|
||||
}
|
||||
|
||||
export async function getPlugins(): Promise<PluginSummary[]> {
|
||||
@@ -510,10 +512,12 @@ export async function previewPluginState(
|
||||
export async function setPluginState(
|
||||
pluginId: string,
|
||||
enabled: boolean,
|
||||
consent?: { version: string; capabilities: string[] },
|
||||
): Promise<PluginStateChange> {
|
||||
try {
|
||||
const response = await rbacApi.patch(`${pluginPath(pluginId)}/state`, {
|
||||
enabled,
|
||||
...consent,
|
||||
});
|
||||
return response.data;
|
||||
} finally {
|
||||
|
||||
@@ -1967,6 +1967,7 @@
|
||||
"syncEntities_other": "Desktop sync"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Enable {{name}}?",
|
||||
"installTitle": "Install {{name}}",
|
||||
"updateTitle": "Update {{name}}",
|
||||
"agreeUpdate": "Agree and update",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { capabilityRisk, orderByRisk, type PluginEntry } from "./plugin-model";
|
||||
|
||||
export interface ConsentRequest {
|
||||
plugin: PluginEntry;
|
||||
mode: "install" | "update" | "upload";
|
||||
mode: "install" | "update" | "upload" | "enable";
|
||||
version: string;
|
||||
/** What the prompt lists: all of them to install, only the new ones to update. */
|
||||
capabilities: string[];
|
||||
@@ -45,17 +45,19 @@ export function PluginConsentPrompt({
|
||||
onCancel={busy ? undefined : onCancel}
|
||||
icon={<PluginIconBox name={plugin.icon} size="sm" />}
|
||||
title={
|
||||
mode === "update"
|
||||
? t("plugins.manager.consent.updateTitle", { name: plugin.name })
|
||||
: mode === "upload"
|
||||
? t("plugins.manager.consent.uploadTitle", { name: plugin.name })
|
||||
: t("plugins.manager.consent.installTitle", { name: plugin.name })
|
||||
mode === "enable"
|
||||
? t("plugins.manager.consent.enableTitle", { name: plugin.name })
|
||||
: mode === "update"
|
||||
? t("plugins.manager.consent.updateTitle", { name: plugin.name })
|
||||
: mode === "upload"
|
||||
? t("plugins.manager.consent.uploadTitle", { name: plugin.name })
|
||||
: t("plugins.manager.consent.installTitle", { name: plugin.name })
|
||||
}
|
||||
description={
|
||||
<Facts>
|
||||
{plugin.author && <span className="truncate">{plugin.author}</span>}
|
||||
<span className="shrink-0">
|
||||
{mode !== "install" && plugin.version
|
||||
{(mode === "update" || mode === "upload") && plugin.version
|
||||
? t("plugins.manager.versionChange", {
|
||||
from: plugin.version,
|
||||
to: version,
|
||||
@@ -75,9 +77,11 @@ export function PluginConsentPrompt({
|
||||
disabled={busy}
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
>
|
||||
{mode === "update"
|
||||
? t("plugins.manager.consent.agreeUpdate")
|
||||
: t("plugins.manager.install")}
|
||||
{mode === "enable"
|
||||
? t("plugins.manager.enable")
|
||||
: mode === "update"
|
||||
? t("plugins.manager.consent.agreeUpdate")
|
||||
: t("plugins.manager.install")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ export function PluginDetail({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy || locked}
|
||||
disabled={busy || plugin.managedByServer}
|
||||
onClick={() => void manager.toggle(plugin)}
|
||||
>
|
||||
{plugin.enabled
|
||||
|
||||
@@ -793,7 +793,7 @@ function InstalledCard({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={busy || locked}
|
||||
disabled={busy || plugin.managedByServer}
|
||||
onClick={() => void manager.toggle(plugin)}
|
||||
>
|
||||
{plugin.enabled
|
||||
@@ -907,7 +907,7 @@ function InstalledRowActions({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={busy || locked}
|
||||
disabled={busy || plugin.managedByServer}
|
||||
onClick={() => void manager.toggle(plugin)}
|
||||
>
|
||||
{plugin.enabled
|
||||
|
||||
@@ -69,6 +69,8 @@ export interface PluginEntry {
|
||||
/** The registry it is listed in or was installed from. */
|
||||
registryId: string | null;
|
||||
registryName: string | null;
|
||||
/** A linked server decides whether it runs here. */
|
||||
managedByServer: boolean;
|
||||
}
|
||||
|
||||
export type PluginSource =
|
||||
@@ -165,6 +167,7 @@ export function mergePlugins(
|
||||
unverified: pluginSource(summary) === "unverified",
|
||||
registryId: entry?.registryId ?? summary.registryId ?? null,
|
||||
registryName: entry?.registryName ?? null,
|
||||
managedByServer: summary.managedByServer ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -208,6 +211,7 @@ export function mergePlugins(
|
||||
unverified: false,
|
||||
registryId: entry.registryId ?? "official",
|
||||
registryName: entry.registryName ?? null,
|
||||
managedByServer: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -248,6 +252,7 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
|
||||
unverified: true,
|
||||
registryId: null,
|
||||
registryName: null,
|
||||
managedByServer: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -342,18 +342,42 @@ export function usePluginsManager() {
|
||||
[failToast],
|
||||
);
|
||||
|
||||
const doEnable = useCallback(
|
||||
async (request: ConsentRequest) =>
|
||||
run(request.plugin.id, async () => {
|
||||
try {
|
||||
const result = await setPluginState(request.plugin.id, true, {
|
||||
version: request.version,
|
||||
capabilities: request.capabilities,
|
||||
});
|
||||
if (result.state === "failed" || result.state === "blocked") {
|
||||
toast.error(
|
||||
t("plugins.manager.toast.failedToStart", {
|
||||
name: request.plugin.name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
failToast(error, "plugins.manager.errors.state", request.plugin.name);
|
||||
}
|
||||
}),
|
||||
[run, t, failToast],
|
||||
);
|
||||
|
||||
const confirmConsent = useCallback(async () => {
|
||||
if (!consent) return;
|
||||
const request = consent;
|
||||
setConsent(null);
|
||||
if (request.mode === "upload" && request.uploadToken) {
|
||||
if (request.mode === "enable") {
|
||||
await doEnable(request);
|
||||
} else if (request.mode === "upload" && request.uploadToken) {
|
||||
await doInstallUpload(request);
|
||||
} else if (request.mode === "install") {
|
||||
await doInstall(request.plugin, request.version, request.capabilities);
|
||||
} else {
|
||||
await doUpdate(request.plugin, request.version, request.capabilities);
|
||||
}
|
||||
}, [consent, doInstall, doUpdate, doInstallUpload]);
|
||||
}, [consent, doInstall, doUpdate, doInstallUpload, doEnable]);
|
||||
|
||||
const nameOf = useCallback(
|
||||
(id: string) => plugins.find((p) => p.id === id)?.name ?? id,
|
||||
@@ -402,6 +426,15 @@ export function usePluginsManager() {
|
||||
failToast(error, "plugins.manager.errors.state", plugin.name);
|
||||
return;
|
||||
}
|
||||
if (enable && registry?.managedByServer) {
|
||||
setConsent({
|
||||
plugin,
|
||||
mode: "enable",
|
||||
version: plugin.version!,
|
||||
capabilities: plugin.capabilities,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await run(plugin.id, async () => {
|
||||
try {
|
||||
const result = await setPluginState(plugin.id, enable);
|
||||
@@ -415,7 +448,7 @@ export function usePluginsManager() {
|
||||
}
|
||||
});
|
||||
},
|
||||
[confirm, t, run, failToast, nameOf],
|
||||
[confirm, t, run, failToast, nameOf, registry?.managedByServer],
|
||||
);
|
||||
|
||||
const retry = useCallback(
|
||||
|
||||
@@ -89,3 +89,26 @@ describe("PluginConsentPrompt", () => {
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows explicit enable consent and only confirms when clicked", () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<PluginConsentPrompt
|
||||
request={{
|
||||
plugin,
|
||||
mode: "enable",
|
||||
version: "1.0.0",
|
||||
capabilities: ["ssh:connect"],
|
||||
}}
|
||||
onCancel={() => {}}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("plugins.manager.consent.enableTitle")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("plugins.capabilities.ssh:connect.title"),
|
||||
).toBeTruthy();
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByText("plugins.manager.enable"));
|
||||
expect(onConfirm).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -23,7 +23,9 @@ const plugin = {
|
||||
contributes: {},
|
||||
versions: [],
|
||||
downloads: null,
|
||||
managedByServer: false,
|
||||
};
|
||||
let linked = false;
|
||||
|
||||
vi.mock("@/plugins/use-plugins-manager", () => ({
|
||||
usePluginsManager: () => ({
|
||||
@@ -32,7 +34,7 @@ vi.mock("@/plugins/use-plugins-manager", () => ({
|
||||
refreshing: false,
|
||||
refresh: vi.fn(),
|
||||
registryError: null,
|
||||
managedByServer: false,
|
||||
managedByServer: linked,
|
||||
busy: new Set(),
|
||||
consent: null,
|
||||
setConsent: vi.fn(),
|
||||
@@ -56,7 +58,11 @@ async function renderTab() {
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
linked = false;
|
||||
plugin.managedByServer = false;
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("PluginsTab", () => {
|
||||
@@ -123,4 +129,17 @@ describe("PluginsTab", () => {
|
||||
);
|
||||
expect(grid?.style.gridTemplateColumns).toContain("auto-fill");
|
||||
});
|
||||
|
||||
it("lets a linked desktop switch a plugin its server does not decide", async () => {
|
||||
linked = true;
|
||||
await renderTab();
|
||||
const toggle = () =>
|
||||
screen.getByRole("button", { name: en.plugins.manager.disable });
|
||||
expect(toggle().hasAttribute("disabled")).toBe(false);
|
||||
|
||||
cleanup();
|
||||
plugin.managedByServer = true;
|
||||
await renderTab();
|
||||
expect(toggle().hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,14 @@ describe("mergePlugins", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("says whether the linked server decides an installed plugin", () => {
|
||||
const rows = mergePlugins(
|
||||
[summary({ managedByServer: true })],
|
||||
[entry({ id: "other" })],
|
||||
);
|
||||
expect(rows.map((row) => row.managedByServer)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it("lists registry plugins that are not installed with the latest capabilities", () => {
|
||||
const rows = mergePlugins(
|
||||
[],
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getPlugins: vi.fn(async () => [
|
||||
{
|
||||
id: "sample-plugin",
|
||||
name: "Sample",
|
||||
version: "1.0.0",
|
||||
enabled: false,
|
||||
state: "loaded",
|
||||
source: "user",
|
||||
capabilities: ["kv:own"],
|
||||
managedByServer: false,
|
||||
contributes: {},
|
||||
},
|
||||
]),
|
||||
getPluginRegistry: vi.fn(async () => ({
|
||||
plugins: [],
|
||||
managedByServer: true,
|
||||
registry: {},
|
||||
})),
|
||||
getDeveloperMode: vi.fn(async () => ({ enabled: false, signedOnly: false })),
|
||||
previewPluginState: vi.fn(async () => ({
|
||||
enable: [],
|
||||
disable: [],
|
||||
missing: [],
|
||||
})),
|
||||
setPluginState: vi.fn(async () => ({ state: "active" })),
|
||||
}));
|
||||
vi.mock("@/api/plugins-api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/api/plugins-api")>()),
|
||||
...api,
|
||||
}));
|
||||
vi.mock("@/components/surface/surface-scope", () => ({
|
||||
useConfirm: () => vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
|
||||
|
||||
import { usePluginsManager } from "@/plugins/use-plugins-manager";
|
||||
|
||||
it("waits for local consent and sends the reviewed version and capabilities", async () => {
|
||||
const { result } = renderHook(() => usePluginsManager());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
const plugin = result.current.plugins[0];
|
||||
await act(async () => {
|
||||
await result.current.toggle(plugin);
|
||||
});
|
||||
expect(result.current.consent).toMatchObject({
|
||||
mode: "enable",
|
||||
version: "1.0.0",
|
||||
capabilities: ["kv:own"],
|
||||
});
|
||||
expect(api.setPluginState).not.toHaveBeenCalled();
|
||||
act(() => result.current.setConsent(null));
|
||||
expect(api.setPluginState).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
await result.current.toggle(plugin);
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.confirmConsent();
|
||||
});
|
||||
expect(api.setPluginState).toHaveBeenCalledExactlyOnceWith(
|
||||
"sample-plugin",
|
||||
true,
|
||||
{ version: "1.0.0", capabilities: ["kv:own"] },
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user