feat: implement plugin management UI

This commit is contained in:
LukeGus committed 2026-10-06 10:35:56 -05:00
1 parent 88d7bc5525
commit 3ed5775ed5
52 files changed
+20072 -41

No files matched your search

@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import { PluginConsentPrompt } from "@/plugins/PluginConsentPrompt";
import { mergePlugins } from "@/plugins/plugin-model";
afterEach(cleanup);
const [plugin] = mergePlugins(
[],
[
{
id: "docker",
name: "Docker",
description: "",
author: "Termix",
category: "",
versions: [],
latestVersion: "1.0.0",
installed: false,
installedVersion: null,
updateAvailable: false,
addedCapabilities: [],
pinnedVersion: null,
autoUpdate: false,
bundled: false,
},
],
);
describe("PluginConsentPrompt", () => {
it("lists notable capabilities and folds the low risk ones", () => {
render(
<PluginConsentPrompt
request={{
plugin,
mode: "install",
version: "1.0.0",
capabilities: ["kv:own", "ssh:connect", "ui:surface"],
}}
onCancel={() => {}}
onConfirm={() => {}}
/>,
);
expect(
screen.getByText("plugins.capabilities.ssh:connect.title"),
).toBeTruthy();
expect(screen.queryByText("plugins.capabilities.kv:own.title")).toBeNull();
expect(screen.getByText("plugins.manager.consent.minor")).toBeTruthy();
});
it("confirms and cancels", () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
render(
<PluginConsentPrompt
request={{
plugin,
mode: "update",
version: "1.1.0",
capabilities: ["notify:send"],
}}
onCancel={onCancel}
onConfirm={onConfirm}
/>,
);
expect(
screen.getByText("plugins.manager.consent.updateExplain"),
).toBeTruthy();
fireEvent.click(screen.getByText("plugins.manager.consent.agreeUpdate"));
fireEvent.click(screen.getByText("common.cancel"));
expect(onConfirm).toHaveBeenCalledOnce();
expect(onCancel).toHaveBeenCalledOnce();
});
it("renders nothing without a request", () => {
const { container } = render(
<PluginConsentPrompt
request={null}
onCancel={() => {}}
onConfirm={() => {}}
/>,
);
expect(container.innerHTML).toBe("");
});
});
+39
View File
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import i18next from "i18next";
import { I18nextProvider, initReactI18next } from "react-i18next";
import { CapabilityRow } from "@/plugins/plugin-bits";
afterEach(cleanup);
describe("CapabilityRow", () => {
it("resolves capability ids that contain a colon", async () => {
const i18n = i18next.createInstance();
await i18n.use(initReactI18next).init({
lng: "en",
resources: {
en: {
translation: {
plugins: {
capabilities: {
"users:impersonate": {
title: "Act as other users",
consequence: "It can do anything a user can.",
},
},
},
},
},
},
});
render(
<I18nextProvider i18n={i18n}>
<CapabilityRow capability="users:impersonate" />
</I18nextProvider>,
);
expect(screen.getByText("Act as other users")).toBeTruthy();
expect(screen.getByText("It can do anything a user can.")).toBeTruthy();
});
});
+174
View File
@@ -0,0 +1,174 @@
import { describe, expect, it } from "vitest";
import type { PluginSummary, RegistryPluginEntry } from "@/api/plugins-api";
import {
categoriesOf,
describeContributions,
formatBytes,
matchesQuery,
mergePlugins,
orderByRisk,
pluginStatus,
sortPlugins,
} from "@/plugins/plugin-model";
function summary(overrides: Partial<PluginSummary> = {}): PluginSummary {
return {
id: "docker",
name: "Docker",
version: "1.0.0",
enabled: true,
state: "active",
contributes: null,
capabilities: ["kv:own"],
...overrides,
};
}
function entry(
overrides: Partial<RegistryPluginEntry> = {},
): RegistryPluginEntry {
return {
id: "docker",
name: "Docker",
description: "Containers",
author: "Termix",
category: "Infrastructure",
versions: [
{
version: "1.1.0",
compatible: true,
capabilities: ["kv:own", "ssh:connect"],
publishedAt: "2026-10-02",
size: 1,
},
],
latestVersion: "1.1.0",
installed: true,
installedVersion: "1.0.0",
updateAvailable: true,
addedCapabilities: ["ssh:connect"],
pinnedVersion: null,
autoUpdate: false,
bundled: true,
...overrides,
};
}
describe("mergePlugins", () => {
it("joins an installed plugin with its registry entry", () => {
const [row] = mergePlugins([summary()], [entry()]);
expect(row).toMatchObject({
installed: true,
version: "1.0.0",
latestVersion: "1.1.0",
updateAvailable: true,
addedCapabilities: ["ssh:connect"],
capabilities: ["kv:own"],
category: "Infrastructure",
inRegistry: true,
status: "running",
});
});
it("lists registry plugins that are not installed with the latest capabilities", () => {
const rows = mergePlugins(
[],
[entry({ installed: false, installedVersion: null })],
);
expect(rows[0]).toMatchObject({
installed: false,
version: null,
status: null,
capabilities: ["kv:own", "ssh:connect"],
});
});
it("keeps installed plugins when the registry is unreachable", () => {
const rows = mergePlugins([summary()], null);
expect(rows).toHaveLength(1);
expect(rows[0].inRegistry).toBe(false);
});
});
describe("pluginStatus", () => {
it("maps loader and stored states", () => {
expect(pluginStatus(summary({ state: "failed" }))).toBe("failed");
expect(pluginStatus(summary({ state: "blocked" }))).toBe("blocked");
expect(pluginStatus(summary({ enabled: false, state: "disabled" }))).toBe(
"disabled",
);
expect(pluginStatus(summary())).toBe("running");
});
});
describe("sorting and filtering", () => {
const rows = mergePlugins(
[],
[
entry({ id: "b", name: "Beta", category: "Monitoring" }),
entry({
id: "a",
name: "Alpha",
category: "Access",
description: "Sign in",
versions: [
{
version: "1.0.0",
compatible: true,
capabilities: [],
publishedAt: "2026-10-05",
size: 1,
},
],
}),
],
);
it("sorts by name, date and category", () => {
expect(sortPlugins(rows, "name").map((r) => r.id)).toEqual(["a", "b"]);
expect(sortPlugins(rows, "updated").map((r) => r.id)).toEqual(["a", "b"]);
expect(sortPlugins(rows, "category").map((r) => r.id)).toEqual(["a", "b"]);
});
it("searches name, description and id", () => {
expect(
rows.filter((r) => matchesQuery(r, "sign")).map((r) => r.id),
).toEqual(["a"]);
expect(rows.filter((r) => matchesQuery(r, " ")).length).toBe(2);
});
it("collects categories", () => {
expect(categoriesOf(rows)).toEqual(["Access", "Monitoring"]);
});
});
describe("helpers", () => {
it("orders capabilities worst first", () => {
expect(orderByRisk(["kv:own", "hosts:write", "credentials:read"])).toEqual([
"credentials:read",
"hosts:write",
"kv:own",
]);
});
it("counts contributions", () => {
expect(
describeContributions({
tabs: [{}, {}],
settings: { admin: [] },
auth: { loginMethods: [{}], secondFactors: [] },
panels: [],
}),
).toEqual([
{ kind: "tabs", count: 2 },
{ kind: "auth", count: 1 },
{ kind: "settings", count: 1 },
]);
});
it("formats sizes", () => {
expect(formatBytes(512)).toBe("512 B");
expect(formatBytes(2048)).toBe("2.0 KB");
expect(formatBytes(50 * 1024 * 1024)).toBe("50 MB");
});
});
+10
View File
@@ -63,9 +63,19 @@ describe("RAIL_ITEMS", () => {
"connections",
"quick-connect",
"sync",
"plugins",
]);
});
it("opens the Plugins tab only for plugin managers", () => {
const plugins = RAIL_ITEMS.find((item) => item.id === "plugins");
expect(plugins).toMatchObject({
kind: "tab",
placement: "footer",
permission: "admin.plugins.manage",
});
});
it("exposes every visible rail item as hideable", () => {
expect(hideableRailIds()).toEqual(
visibleRailItems().map((item) => item.id),