mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: namespace plugin permissions and keep them valid when a plugin is off
This commit is contained in:
1 parent
8431570ff7
commit
cc15559ea1
53 files changed
+31442
-351
No files matched your search
@@ -268,9 +268,25 @@ export async function getHostAccess(
|
||||
}
|
||||
}
|
||||
|
||||
export interface PermissionCatalogItem {
|
||||
permission: string;
|
||||
/** Plugin-relative keys; resolve them with pluginKey(). */
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
}
|
||||
|
||||
export interface PermissionCatalogEntry {
|
||||
group: string;
|
||||
permissions: string[];
|
||||
/** Set for a group a plugin contributed. */
|
||||
pluginId?: string;
|
||||
/** Core groups carry an i18n key, plugin groups their display name. */
|
||||
labelKey?: string;
|
||||
label?: string;
|
||||
icon?: string;
|
||||
/** False once the owning plugin is disabled or gone. */
|
||||
enabled?: boolean;
|
||||
items?: PermissionCatalogItem[];
|
||||
}
|
||||
|
||||
export async function getPermissionsCatalog(): Promise<{
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Where a plugin's i18n keys resolve from.
|
||||
*
|
||||
* A manifest writes plugin-relative keys ("permissions.devices.view.title") so
|
||||
* it never has to know where the strings live. Today they live in core
|
||||
* en.json under `plugins.<pluginId>.`, because the frontend does not load
|
||||
* plugin locale bundles yet. When it does, that subtree moves into
|
||||
* plugins/<id>/locales/en.json as the plugin's own namespace and only this
|
||||
* function changes; no manifest is touched.
|
||||
*/
|
||||
export function pluginKey(pluginId: string, key: string): string {
|
||||
return `plugins.${pluginId}.${key}`;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { icons, type LucideProps } from "lucide-react";
|
||||
import { Puzzle } from "lucide-react";
|
||||
|
||||
/**
|
||||
* A manifest names its icon as a Lucide component name ("Sparkles").
|
||||
*
|
||||
* An unknown or missing name falls back to a generic plugin glyph rather than
|
||||
* rendering nothing, so a plugin shipping a typo still gets a row an admin can
|
||||
* read.
|
||||
*/
|
||||
export function PluginIcon({
|
||||
name,
|
||||
...props
|
||||
}: { name?: string } & LucideProps) {
|
||||
const Icon = (name && icons[name as keyof typeof icons]) || Puzzle;
|
||||
return <Icon {...props} />;
|
||||
}
|
||||
+64
-1
@@ -3953,7 +3953,14 @@
|
||||
"saved": "Role permissions saved",
|
||||
"saveError": "Failed to save role permissions",
|
||||
"save": "Save",
|
||||
"saving": "Saving..."
|
||||
"saving": "Saving...",
|
||||
"pluginDisabled": "Plugin disabled",
|
||||
"groups": {
|
||||
"hosts": "Hosts",
|
||||
"snippets": "Snippets",
|
||||
"credentials": "Credentials",
|
||||
"admin": "Administration"
|
||||
}
|
||||
},
|
||||
"aiGloballyEnabled": "AI assistant",
|
||||
"aiGloballyEnabledDesc": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone.",
|
||||
@@ -5199,6 +5206,62 @@
|
||||
"title": "Add its own screens",
|
||||
"consequence": "Tabs, panels and settings you can hide later."
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use the assistant",
|
||||
"description": "Open the assistant and ask it things."
|
||||
},
|
||||
"manage_providers": {
|
||||
"title": "Manage providers",
|
||||
"description": "Add, edit and remove the AI providers and their API keys."
|
||||
},
|
||||
"apply_proposals": {
|
||||
"title": "Apply proposals",
|
||||
"description": "Let the assistant carry out the changes it suggests."
|
||||
},
|
||||
"services.use": {
|
||||
"title": "Use AI from other plugins",
|
||||
"description": "Other plugins may call the assistant on your behalf."
|
||||
},
|
||||
"secrets.share": {
|
||||
"title": "Share the API key",
|
||||
"description": "Other plugins may use the stored AI API key without seeing it."
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
"permissions": {
|
||||
"view": {
|
||||
"title": "View automations",
|
||||
"description": "See the automations and their history."
|
||||
},
|
||||
"create": {
|
||||
"title": "Create automations",
|
||||
"description": "Add new automations."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edit automations",
|
||||
"description": "Change existing automations."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete automations",
|
||||
"description": "Remove automations."
|
||||
},
|
||||
"run": {
|
||||
"title": "Run automations",
|
||||
"description": "Trigger an automation by hand."
|
||||
}
|
||||
}
|
||||
},
|
||||
"tailscale": {
|
||||
"permissions": {
|
||||
"devices.view": {
|
||||
"title": "View tailnet devices",
|
||||
"description": "See the devices on the connected tailnet."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2252,6 +2252,7 @@ export type {
|
||||
SharePermissionLevel,
|
||||
ShareTarget,
|
||||
PermissionCatalogEntry,
|
||||
PermissionCatalogItem,
|
||||
} from "@/api/rbac-api";
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
updateRole,
|
||||
} from "@/main-axios";
|
||||
import type { PermissionCatalogEntry, Role } from "@/main-axios";
|
||||
import { pluginKey } from "@/lib/plugin-i18n";
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import {
|
||||
@@ -671,8 +673,23 @@ export function AdminRolesSection({
|
||||
{catalog.map((entry) => {
|
||||
const wildcard = `${entry.group}.*`;
|
||||
const wildcardOn = editingPermissions.has(wildcard);
|
||||
// A plugin group with enabled === false is still granted,
|
||||
// it just cannot be reached right now.
|
||||
const offline = entry.enabled === false;
|
||||
const titleFor = (permission: string) => {
|
||||
const item = entry.items?.find(
|
||||
(candidate) => candidate.permission === permission,
|
||||
);
|
||||
if (!item) return null;
|
||||
return entry.pluginId
|
||||
? t(pluginKey(entry.pluginId, item.titleKey))
|
||||
: t(item.titleKey);
|
||||
};
|
||||
return (
|
||||
<div key={entry.group} className="flex flex-col gap-1">
|
||||
<div
|
||||
key={entry.group}
|
||||
className={`flex flex-col gap-1 ${offline ? "opacity-50" : ""}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => togglePermission(wildcard)}
|
||||
className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-left"
|
||||
@@ -684,6 +701,12 @@ export function AdminRolesSection({
|
||||
<Check className="size-2 text-background" />
|
||||
)}
|
||||
</span>
|
||||
{entry.pluginId && (
|
||||
<PluginIcon
|
||||
name={entry.icon}
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
wildcardOn
|
||||
@@ -691,13 +714,22 @@ export function AdminRolesSection({
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{entry.group}.*
|
||||
{entry.label ??
|
||||
(entry.labelKey
|
||||
? t(entry.labelKey)
|
||||
: entry.group)}
|
||||
</span>
|
||||
{offline && (
|
||||
<span className="px-1 border border-border text-[9px] font-normal normal-case tracking-normal text-muted-foreground">
|
||||
{t("admin.rolePermissions.pluginDisabled")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex flex-col gap-0.5 pl-4">
|
||||
{entry.permissions.map((permission) => {
|
||||
const checked =
|
||||
wildcardOn || editingPermissions.has(permission);
|
||||
const title = titleFor(permission);
|
||||
return (
|
||||
<button
|
||||
key={permission}
|
||||
@@ -712,6 +744,11 @@ export function AdminRolesSection({
|
||||
<Check className="size-2 text-background" />
|
||||
)}
|
||||
</span>
|
||||
{title && (
|
||||
<span className="text-foreground">
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{permission}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* The role editor's permission catalog: plugin grouping, the disabled state,
|
||||
* and what actually gets saved.
|
||||
*
|
||||
* The behaviour that matters is that a disabled plugin's permission stays
|
||||
* ticked and stays in the payload. Dropping it would silently revoke a grant
|
||||
* just because the plugin happened to be off.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { AdminRolesSection } from "../../sidebar/AdminManagementSections";
|
||||
import type { PermissionCatalogEntry, Role } from "../../main-axios";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getPermissionsCatalog: vi.fn(),
|
||||
updateRole: vi.fn(
|
||||
async (_roleId: number, _update: { permissions?: string[] }) => ({}),
|
||||
),
|
||||
deleteRole: vi.fn(async () => ({})),
|
||||
getRoleMembers: vi.fn(async () => ({ members: [] as unknown[] })),
|
||||
getSessions: vi.fn(async () => ({ sessions: [] as unknown[] })),
|
||||
revokeSession: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => api);
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const CATALOG: PermissionCatalogEntry[] = [
|
||||
{
|
||||
group: "hosts",
|
||||
labelKey: "admin.rolePermissions.groups.hosts",
|
||||
permissions: ["hosts.view", "hosts.create"],
|
||||
},
|
||||
{
|
||||
group: "ai",
|
||||
pluginId: "ai",
|
||||
label: "AI Assistant",
|
||||
icon: "Sparkles",
|
||||
enabled: false,
|
||||
permissions: ["ai.use"],
|
||||
items: [
|
||||
{
|
||||
permission: "ai.use",
|
||||
titleKey: "permissions.use.title",
|
||||
descriptionKey: "permissions.use.description",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "automations",
|
||||
pluginId: "automations",
|
||||
label: "Automations",
|
||||
icon: "Workflow",
|
||||
enabled: true,
|
||||
permissions: ["automations.run"],
|
||||
items: [
|
||||
{
|
||||
permission: "automations.run",
|
||||
titleKey: "permissions.run.title",
|
||||
descriptionKey: "permissions.run.description",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function makeRole(permissions: string[]): Role {
|
||||
return {
|
||||
id: 7,
|
||||
name: "operators",
|
||||
displayName: "Operators",
|
||||
description: null,
|
||||
isSystem: false,
|
||||
permissions,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
}
|
||||
|
||||
function renderSection(role: Role) {
|
||||
return render(
|
||||
<AdminRolesSection
|
||||
open
|
||||
onToggle={() => {}}
|
||||
roles={[role]}
|
||||
setRoles={() => {}}
|
||||
showCreateRole={false}
|
||||
setShowCreateRole={() => {}}
|
||||
newRoleName=""
|
||||
setNewRoleName={() => {}}
|
||||
newRoleDisplayName=""
|
||||
setNewRoleDisplayName={() => {}}
|
||||
newRoleDescription=""
|
||||
setNewRoleDescription={() => {}}
|
||||
handleCreateRole={() => {}}
|
||||
createRoleLoading={false}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
async function openEditor(role: Role) {
|
||||
const user = userEvent.setup();
|
||||
renderSection(role);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /admin.rolePermissions.editAction/i }),
|
||||
);
|
||||
await waitFor(() => expect(api.getPermissionsCatalog).toHaveBeenCalled());
|
||||
return user;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
api.getPermissionsCatalog.mockResolvedValue({ catalog: CATALOG });
|
||||
});
|
||||
|
||||
describe("role permission groups", () => {
|
||||
it("labels a core group by its i18n key, not the raw wildcard", async () => {
|
||||
await openEditor(makeRole([]));
|
||||
|
||||
expect(screen.getByText("admin.rolePermissions.groups.hosts")).toBeTruthy();
|
||||
expect(screen.queryByText("hosts.*")).toBeNull();
|
||||
});
|
||||
|
||||
it("labels a plugin group with the plugin's display name", async () => {
|
||||
await openEditor(makeRole([]));
|
||||
|
||||
expect(screen.getByText("AI Assistant")).toBeTruthy();
|
||||
expect(screen.getByText("Automations")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows a plugin permission's title beside its raw id", async () => {
|
||||
await openEditor(makeRole([]));
|
||||
|
||||
expect(
|
||||
screen.getByText("plugins.automations.permissions.run.title"),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("automations.run")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("marks a disabled plugin's group", async () => {
|
||||
await openEditor(makeRole([]));
|
||||
|
||||
expect(
|
||||
screen.getByText("admin.rolePermissions.pluginDisabled"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not mark an enabled plugin's group", async () => {
|
||||
await openEditor(makeRole([]));
|
||||
|
||||
expect(
|
||||
screen.getAllByText("admin.rolePermissions.pluginDisabled"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("lists a group once per catalog entry", async () => {
|
||||
await openEditor(makeRole([]));
|
||||
|
||||
expect(screen.getAllByText("AI Assistant")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saving a role", () => {
|
||||
// The regression A5 fixes: the permission must survive the round trip even
|
||||
// though its plugin is disabled.
|
||||
it("keeps a disabled plugin's permission in the payload", async () => {
|
||||
const role = makeRole(["hosts.view", "ai.use"]);
|
||||
const user = await openEditor(role);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /admin.rolePermissions.save/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(api.updateRole).toHaveBeenCalled());
|
||||
const [, payload] = api.updateRole.mock.calls[0];
|
||||
expect(payload.permissions).toContain("ai.use");
|
||||
expect(payload.permissions).toContain("hosts.view");
|
||||
});
|
||||
|
||||
it("still lets a disabled plugin's permission be revoked deliberately", async () => {
|
||||
const role = makeRole(["hosts.view", "ai.use"]);
|
||||
const user = await openEditor(role);
|
||||
|
||||
await user.click(screen.getByText("ai.use"));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /admin.rolePermissions.save/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(api.updateRole).toHaveBeenCalled());
|
||||
const [, payload] = api.updateRole.mock.calls[0];
|
||||
expect(payload.permissions).not.toContain("ai.use");
|
||||
expect(payload.permissions).toContain("hosts.view");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user