mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
718 lines
19 KiB
TypeScript
718 lines
19 KiB
TypeScript
import { rbacApi } from "@/main-axios";
|
|
import type { ChoiceAdjustment, PluginChoice } from "@/types/plugin-onboarding";
|
|
import type {
|
|
ChangelogRelease,
|
|
ReleaseNotes,
|
|
} from "@termix-ssh/plugin-sdk/changelog";
|
|
import type { PluginEnvVar } from "@termix-ssh/plugin-sdk/docs";
|
|
|
|
export type { PluginChoice };
|
|
|
|
export interface PluginTabContribution {
|
|
id: string;
|
|
titleKey: string;
|
|
icon: string;
|
|
openFrom: string[];
|
|
}
|
|
|
|
export type PluginSettingsFieldType =
|
|
| "boolean"
|
|
| "string"
|
|
| "number"
|
|
| "select"
|
|
| "multiselect"
|
|
| "secret"
|
|
| "textarea"
|
|
| "json"
|
|
| "custom";
|
|
|
|
export interface PluginSettingsField {
|
|
key: string;
|
|
type: PluginSettingsFieldType;
|
|
labelKey?: string;
|
|
descriptionKey?: string;
|
|
placeholderKey?: string;
|
|
default?: unknown;
|
|
options?: { value: string; labelKey: string }[];
|
|
min?: number;
|
|
max?: number;
|
|
requires?: string;
|
|
permission?: string;
|
|
group?: string;
|
|
component?: string;
|
|
hidden?: boolean;
|
|
defaultable?: boolean;
|
|
defaultLevels?: Array<"admin" | "user" | "folder">;
|
|
personal?: boolean;
|
|
secretKeys?: string[];
|
|
shareRead?: "connect" | "view" | "edit" | "manage";
|
|
ownerOnly?: boolean;
|
|
}
|
|
|
|
export interface PluginHostSettingsContribution {
|
|
enableKey?: string;
|
|
enableLabelKey?: string;
|
|
enableDescriptionKey?: string;
|
|
enableDefault?: boolean;
|
|
editorGroup?: "top" | "ssh";
|
|
editorOrder?: number;
|
|
fields: PluginSettingsField[];
|
|
}
|
|
|
|
/** A plugin's Appearance defaults for each interface preset. */
|
|
export type PluginUiPresets = Record<
|
|
"simple" | "balanced" | "advanced",
|
|
Record<string, unknown>
|
|
>;
|
|
|
|
export interface PluginSettingsContribution {
|
|
admin?: PluginSettingsField[];
|
|
user?: PluginSettingsField[];
|
|
host?: PluginHostSettingsContribution;
|
|
}
|
|
|
|
/** A panel or dashboard card, declared so its owner is known while it is off. */
|
|
export interface PluginViewContribution {
|
|
id: string;
|
|
titleKey: string;
|
|
icon?: string;
|
|
}
|
|
|
|
export interface PluginLayoutContribution {
|
|
id: string;
|
|
label: string;
|
|
description?: string;
|
|
defaultTheme?: string;
|
|
default?: boolean;
|
|
}
|
|
|
|
export interface PluginThemeContribution {
|
|
id: string;
|
|
label: string;
|
|
mode: "light" | "dark";
|
|
css: string;
|
|
swatch?: { background: string; panel: string; text: string };
|
|
}
|
|
|
|
export interface PluginContributions {
|
|
tabs?: PluginTabContribution[];
|
|
panels?: PluginViewContribution[];
|
|
dashboardCards?: PluginViewContribution[];
|
|
/** The frontend also runs on anonymous guest pages. */
|
|
guest?: boolean;
|
|
settings?: PluginSettingsContribution;
|
|
permissions?: { name: string; titleKey: string; descriptionKey: string }[];
|
|
uiPresets?: PluginUiPresets;
|
|
layouts?: PluginLayoutContribution[];
|
|
themes?: PluginThemeContribution[];
|
|
}
|
|
|
|
export {
|
|
isRedactedSecret,
|
|
type RedactedSecret,
|
|
} from "@termix-ssh/plugin-sdk/settings";
|
|
|
|
export type PluginSettingsValues = Record<string, unknown>;
|
|
|
|
/** Per-field messages from a rejected PUT, keyed by field key. */
|
|
export interface PluginSettingsErrors {
|
|
[key: string]: string;
|
|
}
|
|
|
|
export class PluginSettingsValidationError extends Error {
|
|
readonly errors: PluginSettingsErrors;
|
|
|
|
constructor(errors: PluginSettingsErrors) {
|
|
super("Some settings were rejected");
|
|
this.name = "PluginSettingsValidationError";
|
|
this.errors = errors;
|
|
}
|
|
}
|
|
|
|
function settingsPath(pluginId: string, suffix: string): string {
|
|
return `/plugins/${encodeURIComponent(pluginId)}/settings/${suffix}`;
|
|
}
|
|
|
|
/** Turns a 400 carrying per-field errors into something a form can render. */
|
|
async function putSettings(
|
|
path: string,
|
|
values: PluginSettingsValues,
|
|
): Promise<PluginSettingsValues> {
|
|
try {
|
|
const response = await rbacApi.put(path, values);
|
|
return response.data?.values ?? {};
|
|
} catch (error) {
|
|
const data = (
|
|
error as { response?: { status?: number; data?: { errors?: unknown } } }
|
|
).response;
|
|
if (data?.status === 400 && data.data?.errors) {
|
|
throw new PluginSettingsValidationError(
|
|
data.data.errors as PluginSettingsErrors,
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function getPluginAdminSettings(
|
|
pluginId: string,
|
|
): Promise<PluginSettingsValues> {
|
|
const response = await rbacApi.get(settingsPath(pluginId, "admin"));
|
|
return response.data?.values ?? {};
|
|
}
|
|
|
|
/** Tells the plugin's own frontend, through app.onSettingsChanged. */
|
|
export const PLUGIN_SETTINGS_CHANGED_EVENT = "termix:plugin-settings-changed";
|
|
|
|
function announceSettingsChange(
|
|
pluginId: string,
|
|
scope: "admin" | "user" | "host",
|
|
hostId?: number,
|
|
): void {
|
|
if (typeof window === "undefined") return;
|
|
window.dispatchEvent(
|
|
new CustomEvent(PLUGIN_SETTINGS_CHANGED_EVENT, {
|
|
detail: { pluginId, scope, hostId },
|
|
}),
|
|
);
|
|
}
|
|
|
|
export async function updatePluginAdminSettings(
|
|
pluginId: string,
|
|
values: PluginSettingsValues,
|
|
): Promise<PluginSettingsValues> {
|
|
const saved = await putSettings(settingsPath(pluginId, "admin"), values);
|
|
announceSettingsChange(pluginId, "admin");
|
|
return saved;
|
|
}
|
|
|
|
export async function getPluginUserSettings(
|
|
pluginId: string,
|
|
): Promise<PluginSettingsValues> {
|
|
const response = await rbacApi.get(settingsPath(pluginId, "user"));
|
|
return response.data?.values ?? {};
|
|
}
|
|
|
|
export async function updatePluginUserSettings(
|
|
pluginId: string,
|
|
values: PluginSettingsValues,
|
|
): Promise<PluginSettingsValues> {
|
|
const saved = await putSettings(settingsPath(pluginId, "user"), values);
|
|
announceSettingsChange(pluginId, "user");
|
|
return saved;
|
|
}
|
|
|
|
export async function getPluginHostSettings(
|
|
pluginId: string,
|
|
hostId: number,
|
|
): Promise<PluginSettingsValues> {
|
|
const response = await rbacApi.get(
|
|
settingsPath(pluginId, `host/${encodeURIComponent(String(hostId))}`),
|
|
);
|
|
return response.data?.values ?? {};
|
|
}
|
|
|
|
export async function updatePluginHostSettings(
|
|
pluginId: string,
|
|
hostId: number,
|
|
values: PluginSettingsValues,
|
|
): Promise<PluginSettingsValues> {
|
|
const saved = await putSettings(
|
|
settingsPath(pluginId, `host/${encodeURIComponent(String(hostId))}`),
|
|
values,
|
|
);
|
|
announceSettingsChange(pluginId, "host", hostId);
|
|
return saved;
|
|
}
|
|
|
|
/**
|
|
* What GET /plugins returns.
|
|
*
|
|
* The admin-only fields are absent for a caller without
|
|
* admin.plugins.manage: what a plugin may do, what it has been granted and
|
|
* why it failed are operational details the shell does not need.
|
|
*/
|
|
export interface PluginSummary {
|
|
id: string;
|
|
name: string;
|
|
version: string;
|
|
enabled: boolean;
|
|
/** enabled | disabled | blocked | failed, or the loader's live state. */
|
|
state: string;
|
|
/** A linked desktop leaves this plugin to its server. */
|
|
runsOnServer?: boolean;
|
|
contributes: PluginContributions | null;
|
|
/** Lucide icon name from the manifest. */
|
|
icon?: string;
|
|
dependencies?: Record<string, string>;
|
|
optionalDependencies?: Record<string, string>;
|
|
/** A built frontend bundle is served at /plugin-assets/<id>/frontend.js. */
|
|
frontend?: boolean;
|
|
/** A frontend.css sits beside the bundle. */
|
|
css?: boolean;
|
|
/** Cache key for the bundle; changes when it is rebuilt. */
|
|
assetVersion?: string | null;
|
|
/** "en" plus the xx_YY names of shipped translations. */
|
|
locales?: string[];
|
|
/** Cache key for the theme CSS files; null without themes. */
|
|
themeVersion?: string | null;
|
|
/** The app is drawn with this plugin by default; it cannot be uninstalled. */
|
|
defaultLook?: boolean;
|
|
|
|
tier?: string;
|
|
source?: string;
|
|
/** Capabilities this plugin's manifest declares. Admins only. */
|
|
capabilities?: string[];
|
|
/** The subset of `capabilities` actually granted. Admins only. */
|
|
grantedCapabilities?: string[];
|
|
/** Why the plugin is blocked or failed. Admins only. */
|
|
lastError?: string | null;
|
|
/** Paths a running plugin serves without login. Admins only. */
|
|
publicRoutes?: { http: string[]; ws: string[] };
|
|
registryId?: string | null;
|
|
autoUpdate?: boolean;
|
|
pinnedVersion?: string | null;
|
|
channel?: PluginChannel;
|
|
description?: string;
|
|
author?: string;
|
|
repository?: string;
|
|
/** A YouTube video id from the manifest. Admins only. */
|
|
videoId?: string;
|
|
/** What the plugin does, from the manifest. Admins only. */
|
|
features?: string[];
|
|
/** https docs link from the manifest. */
|
|
docs?: string;
|
|
/** 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[]> {
|
|
const response = await rbacApi.get("/plugins");
|
|
return Array.isArray(response.data) ? response.data : [];
|
|
}
|
|
|
|
/** Fired after any change to installed plugins; the loader resyncs on it. */
|
|
export const PLUGINS_CHANGED_EVENT = "termix:plugins-changed";
|
|
|
|
function announcePluginsChanged(): void {
|
|
window.dispatchEvent(new CustomEvent(PLUGINS_CHANGED_EVENT));
|
|
}
|
|
|
|
export type PluginChannel = "stable" | "beta";
|
|
|
|
export interface RegistryPluginVersion {
|
|
version: string;
|
|
/** A beta, offered only to plugins on the beta channel. */
|
|
prerelease?: boolean;
|
|
compatible: boolean;
|
|
/** The Termix range this version needs, when this build is too old. */
|
|
needsTermix?: string | null;
|
|
capabilities: string[];
|
|
publishedAt?: string;
|
|
releaseNotesUrl?: string;
|
|
notes?: ReleaseNotes;
|
|
size: number;
|
|
}
|
|
|
|
export type RegistryTier = "official" | "community" | "custom";
|
|
|
|
export interface RegistryPluginEntry {
|
|
id: string;
|
|
/** Which registry this entry comes from. */
|
|
registryId?: string;
|
|
registryName?: string;
|
|
tier?: RegistryTier;
|
|
name: string;
|
|
description: string;
|
|
author: string;
|
|
category: string;
|
|
repository?: string;
|
|
icon?: string;
|
|
videoId?: string;
|
|
features?: string[];
|
|
docs?: string;
|
|
/** The manifest's contributes keys, like layouts or themes. */
|
|
contributes?: string[];
|
|
versions: RegistryPluginVersion[];
|
|
/** Newest release on the plugin's channel. */
|
|
latestVersion: string | null;
|
|
/** Newest beta, when one is newer than stable. */
|
|
latestBeta?: string | null;
|
|
channel?: PluginChannel;
|
|
installed: boolean;
|
|
installedVersion: string | null;
|
|
updateAvailable: boolean;
|
|
addedCapabilities: string[];
|
|
pinnedVersion: string | null;
|
|
autoUpdate: boolean;
|
|
bundled: boolean;
|
|
/** How often the release files were downloaded, from the registry. */
|
|
downloads: number | null;
|
|
}
|
|
|
|
export interface RegistryListingSource {
|
|
id: string;
|
|
name: string;
|
|
tier: RegistryTier;
|
|
url: string;
|
|
lastCheckedAt: string | null;
|
|
error: string | null;
|
|
plugins: number;
|
|
}
|
|
|
|
export interface RegistryListing {
|
|
registry: {
|
|
id: string;
|
|
url: string;
|
|
lastCheckedAt: string | null;
|
|
error: string | null;
|
|
};
|
|
registries?: RegistryListingSource[];
|
|
managedByServer: boolean;
|
|
plugins: RegistryPluginEntry[];
|
|
}
|
|
|
|
export interface PluginStateChange {
|
|
id: string;
|
|
enabled: boolean;
|
|
enable: string[];
|
|
disable: string[];
|
|
missing: string[];
|
|
state?: string;
|
|
}
|
|
|
|
export interface PluginDataSummary {
|
|
id: string;
|
|
tables: Array<{ name: string; rows: number | null }>;
|
|
kvKeys: number;
|
|
settings: { admin: number; user: number; host: number; secret: number };
|
|
migrations: Array<{ id: string; appliedAt: string | null }>;
|
|
grants: Array<{
|
|
capability: string;
|
|
source: string;
|
|
grantedAt: string | null;
|
|
}>;
|
|
filesBytes: number;
|
|
}
|
|
|
|
export interface UpdateAllResult {
|
|
updated: Array<{ id: string; version: string }>;
|
|
needsReview: Array<{ id: string; capabilities: string[] }>;
|
|
failed: Array<{ id: string; error: string }>;
|
|
}
|
|
|
|
const pluginPath = (pluginId: string) =>
|
|
`/plugins/${encodeURIComponent(pluginId)}`;
|
|
|
|
export async function getPluginRegistry(
|
|
refresh = false,
|
|
): Promise<RegistryListing> {
|
|
const response = await rbacApi.get("/plugins/registry", {
|
|
params: refresh ? { refresh: 1 } : undefined,
|
|
});
|
|
return response.data;
|
|
}
|
|
|
|
/** `capabilities` is exactly what the consent prompt showed. */
|
|
export async function installPlugin(
|
|
pluginId: string,
|
|
options: { version?: string; capabilities: string[]; registryId?: string },
|
|
): Promise<{ id: string; version: string; state: string }> {
|
|
try {
|
|
const response = await rbacApi.post(
|
|
`${pluginPath(pluginId)}/install`,
|
|
options,
|
|
);
|
|
return response.data;
|
|
} finally {
|
|
announcePluginsChanged();
|
|
}
|
|
}
|
|
|
|
export async function updatePlugin(
|
|
pluginId: string,
|
|
options: {
|
|
version?: string;
|
|
acceptCapabilities?: boolean;
|
|
capabilities?: string[];
|
|
} = {},
|
|
): Promise<{ id: string; version: string; state: string }> {
|
|
try {
|
|
const response = await rbacApi.post(
|
|
`${pluginPath(pluginId)}/update`,
|
|
options,
|
|
);
|
|
return response.data;
|
|
} finally {
|
|
announcePluginsChanged();
|
|
}
|
|
}
|
|
|
|
export interface CustomRegistryInfo {
|
|
id: string;
|
|
name: string;
|
|
url: string;
|
|
keyId: string;
|
|
enabled: boolean;
|
|
addedAt: string;
|
|
}
|
|
|
|
export interface RegistriesState {
|
|
managedByServer: boolean;
|
|
official: { id: string; url: string };
|
|
community: { id: string; url: string; enabled: boolean };
|
|
custom: CustomRegistryInfo[];
|
|
}
|
|
|
|
export interface RegistryPreview {
|
|
id: string;
|
|
name: string;
|
|
url: string;
|
|
keyId: string;
|
|
plugins: number;
|
|
}
|
|
|
|
export async function getRegistries(): Promise<RegistriesState> {
|
|
const response = await rbacApi.get("/plugins/registries");
|
|
return response.data;
|
|
}
|
|
|
|
export async function previewRegistry(url: string): Promise<RegistryPreview> {
|
|
const response = await rbacApi.post("/plugins/registries/preview", { url });
|
|
return response.data;
|
|
}
|
|
|
|
export async function addRegistry(
|
|
url: string,
|
|
keyId: string,
|
|
): Promise<CustomRegistryInfo> {
|
|
const response = await rbacApi.post("/plugins/registries", { url, keyId });
|
|
return response.data;
|
|
}
|
|
|
|
export async function setRegistryEnabled(
|
|
id: string,
|
|
enabled: boolean,
|
|
): Promise<void> {
|
|
await rbacApi.patch(`/plugins/registries/${encodeURIComponent(id)}`, {
|
|
enabled,
|
|
});
|
|
}
|
|
|
|
export async function removeRegistry(id: string): Promise<void> {
|
|
await rbacApi.delete(`/plugins/registries/${encodeURIComponent(id)}`);
|
|
}
|
|
|
|
export async function updateAllPlugins(): Promise<UpdateAllResult> {
|
|
try {
|
|
const response = await rbacApi.post("/plugins/update-all");
|
|
return response.data;
|
|
} finally {
|
|
announcePluginsChanged();
|
|
}
|
|
}
|
|
|
|
export async function uninstallPlugin(pluginId: string): Promise<void> {
|
|
try {
|
|
await rbacApi.delete(pluginPath(pluginId));
|
|
} finally {
|
|
announcePluginsChanged();
|
|
}
|
|
}
|
|
|
|
/** Puts a bundled plugin back to the copy that shipped with this install. */
|
|
export async function restoreBundledPlugin(pluginId: string): Promise<void> {
|
|
try {
|
|
await rbacApi.post(`${pluginPath(pluginId)}/restore-bundled`);
|
|
} finally {
|
|
announcePluginsChanged();
|
|
}
|
|
}
|
|
|
|
export async function previewPluginState(
|
|
pluginId: string,
|
|
enabled: boolean,
|
|
): Promise<PluginStateChange> {
|
|
const response = await rbacApi.patch(
|
|
`${pluginPath(pluginId)}/state`,
|
|
{ enabled },
|
|
{ params: { dryRun: 1 } },
|
|
);
|
|
return response.data;
|
|
}
|
|
|
|
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 {
|
|
announcePluginsChanged();
|
|
}
|
|
}
|
|
|
|
export async function retryPlugin(pluginId: string): Promise<void> {
|
|
try {
|
|
await rbacApi.post(`${pluginPath(pluginId)}/retry`);
|
|
} finally {
|
|
announcePluginsChanged();
|
|
}
|
|
}
|
|
|
|
export async function setPluginOptions(
|
|
pluginId: string,
|
|
options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel },
|
|
): Promise<{
|
|
autoUpdate: boolean;
|
|
pinnedVersion: string | null;
|
|
channel: PluginChannel;
|
|
}> {
|
|
const response = await rbacApi.patch(
|
|
`${pluginPath(pluginId)}/options`,
|
|
options,
|
|
);
|
|
return response.data;
|
|
}
|
|
|
|
export async function setAllPluginChannels(
|
|
channel: PluginChannel,
|
|
): Promise<{ changed: string[] }> {
|
|
const response = await rbacApi.post("/plugins/channel", { channel });
|
|
return response.data;
|
|
}
|
|
|
|
export async function getPluginData(
|
|
pluginId: string,
|
|
): Promise<PluginDataSummary> {
|
|
const response = await rbacApi.get(`${pluginPath(pluginId)}/data`);
|
|
return response.data;
|
|
}
|
|
|
|
export async function getPluginChangelog(
|
|
pluginId: string,
|
|
): Promise<ChangelogRelease[]> {
|
|
const response = await rbacApi.get(`${pluginPath(pluginId)}/changelog`);
|
|
return response.data?.releases ?? [];
|
|
}
|
|
|
|
export async function deletePluginData(pluginId: string): Promise<void> {
|
|
try {
|
|
await rbacApi.delete(`${pluginPath(pluginId)}/data`);
|
|
} finally {
|
|
announcePluginsChanged();
|
|
}
|
|
}
|
|
|
|
export interface DeveloperModeState {
|
|
enabled: boolean;
|
|
/** TERMIX_REQUIRE_SIGNED_PLUGINS blocks file installs even in developer mode. */
|
|
signedOnly: boolean;
|
|
}
|
|
|
|
export async function getDeveloperMode(): Promise<DeveloperModeState> {
|
|
const response = await rbacApi.get("/plugins/developer-mode");
|
|
return response.data;
|
|
}
|
|
|
|
/** Fired after developer mode changes; detail is the new value. */
|
|
export const DEVELOPER_MODE_CHANGED_EVENT = "termix:plugin-developer-mode";
|
|
|
|
export async function setDeveloperMode(enabled: boolean): Promise<void> {
|
|
await rbacApi.put("/plugins/developer-mode", { enabled });
|
|
window.dispatchEvent(
|
|
new CustomEvent(DEVELOPER_MODE_CHANGED_EVENT, { detail: enabled }),
|
|
);
|
|
}
|
|
|
|
export interface PluginUploadPreview {
|
|
token: string;
|
|
id: string;
|
|
name: string;
|
|
version: string;
|
|
description: string;
|
|
author: string;
|
|
features?: string[];
|
|
capabilities: string[];
|
|
replaces: string | null;
|
|
}
|
|
|
|
export async function uploadPlugin(file: Blob): Promise<PluginUploadPreview> {
|
|
const response = await rbacApi.post("/plugins/upload", file, {
|
|
headers: { "Content-Type": "application/octet-stream" },
|
|
});
|
|
return response.data;
|
|
}
|
|
|
|
export async function installUploadedPlugin(
|
|
token: string,
|
|
capabilities: string[],
|
|
): Promise<{ id: string; version: string; state: string }> {
|
|
try {
|
|
const response = await rbacApi.post(
|
|
`/plugins/upload/${encodeURIComponent(token)}/install`,
|
|
{ capabilities },
|
|
);
|
|
return response.data;
|
|
} finally {
|
|
announcePluginsChanged();
|
|
}
|
|
}
|
|
|
|
export interface OnboardingPluginInfo {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
icon: string | null;
|
|
category: string;
|
|
version: string;
|
|
source: "bundled" | "user";
|
|
state: string;
|
|
dependencies: string[];
|
|
recommended: boolean;
|
|
consent: boolean;
|
|
}
|
|
|
|
export interface OnboardingPluginList {
|
|
pending: boolean;
|
|
/** On an upgrade the picker starts from what is on, not the defaults. */
|
|
reason: "fresh" | "upgrade" | null;
|
|
managedByLinkedServer: boolean;
|
|
plugins: OnboardingPluginInfo[];
|
|
}
|
|
|
|
export interface OnboardingApplyResult {
|
|
resolved: Record<string, PluginChoice>;
|
|
adjustments: ChoiceAdjustment[];
|
|
enabled: string[];
|
|
disabled: string[];
|
|
removed: string[];
|
|
failed: { id: string; error: string; code?: string }[];
|
|
}
|
|
|
|
export async function getOnboardingPlugins(): Promise<OnboardingPluginList> {
|
|
const response = await rbacApi.get("/plugins/onboarding");
|
|
return response.data;
|
|
}
|
|
|
|
export async function applyOnboardingPlugins(
|
|
choices: Record<string, PluginChoice>,
|
|
): Promise<OnboardingApplyResult> {
|
|
try {
|
|
const response = await rbacApi.post("/plugins/onboarding/apply", {
|
|
choices,
|
|
});
|
|
return response.data;
|
|
} finally {
|
|
announcePluginsChanged();
|
|
}
|
|
}
|