mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: give plugins their own settings and one settings screen
This commit is contained in:
1 parent
cc15559ea1
commit
49f0286a14
66 files changed
+36497
-451
No files matched your search
+13
-18
@@ -126,14 +126,11 @@ const SessionLogsPanel = lazy(() =>
|
||||
default: m.SessionLogsPanel,
|
||||
})),
|
||||
);
|
||||
const UserProfilePanel = lazy(() =>
|
||||
import("@/sidebar/UserProfilePanel").then((m) => ({
|
||||
default: m.UserProfilePanel,
|
||||
})),
|
||||
);
|
||||
const AdminSettingsPanel = lazy(() =>
|
||||
import("@/sidebar/AdminSettingsPanel").then((m) => ({
|
||||
default: m.AdminSettingsPanel,
|
||||
// One screen behind both rail entries. It lazy-loads the profile and admin
|
||||
// panels itself, so the shell no longer imports either directly.
|
||||
const SettingsScreen = lazy(() =>
|
||||
import("@/settings/SettingsScreen").then((m) => ({
|
||||
default: m.SettingsScreen,
|
||||
})),
|
||||
);
|
||||
const CredentialsPanel = lazy(() =>
|
||||
@@ -2882,9 +2879,14 @@ export function AppShell({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{railView === "user-profile" && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<UserProfilePanel
|
||||
{(railView === "user-profile" ||
|
||||
(railView === "admin-settings" && showAdminUI)) && (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<SettingsScreen
|
||||
initialSection={
|
||||
railView === "admin-settings" ? "admin" : "profile"
|
||||
}
|
||||
isAdmin={showAdminUI}
|
||||
username={username}
|
||||
onLogout={onLogout}
|
||||
userPrefs={userPrefs}
|
||||
@@ -2896,13 +2898,6 @@ export function AppShell({
|
||||
onRemoteSyncReconnectHandled={() =>
|
||||
setRemoteSyncReconnectRequested(false)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{railView === "admin-settings" && showAdminUI && (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<AdminSettingsPanel
|
||||
onEditingChange={setSidebarEditing}
|
||||
onOpenHostTab={(host) => {
|
||||
connectHost(host);
|
||||
|
||||
@@ -7,8 +7,153 @@ export interface PluginTabContribution {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface PluginHostSettingsContribution {
|
||||
enableKey?: string;
|
||||
enableLabelKey?: string;
|
||||
fields: PluginSettingsField[];
|
||||
}
|
||||
|
||||
export interface PluginSettingsContribution {
|
||||
admin?: PluginSettingsField[];
|
||||
user?: PluginSettingsField[];
|
||||
host?: PluginHostSettingsContribution;
|
||||
}
|
||||
|
||||
export interface PluginContributions {
|
||||
tabs?: PluginTabContribution[];
|
||||
settings?: PluginSettingsContribution;
|
||||
}
|
||||
|
||||
/** A secret as it arrives from the server. The value never leaves the server. */
|
||||
export interface RedactedSecret {
|
||||
set: boolean;
|
||||
}
|
||||
|
||||
export function isRedactedSecret(value: unknown): value is RedactedSecret {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
typeof (value as RedactedSecret).set === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
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 ?? {};
|
||||
}
|
||||
|
||||
export async function updatePluginAdminSettings(
|
||||
pluginId: string,
|
||||
values: PluginSettingsValues,
|
||||
): Promise<PluginSettingsValues> {
|
||||
return putSettings(settingsPath(pluginId, "admin"), values);
|
||||
}
|
||||
|
||||
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> {
|
||||
return putSettings(settingsPath(pluginId, "user"), values);
|
||||
}
|
||||
|
||||
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> {
|
||||
return putSettings(
|
||||
settingsPath(pluginId, `host/${encodeURIComponent(String(hostId))}`),
|
||||
values,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,6 +171,8 @@ export interface PluginSummary {
|
||||
/** enabled | disabled | blocked | failed, or the loader's live state. */
|
||||
state: string;
|
||||
contributes: PluginContributions | null;
|
||||
/** Lucide icon name from the manifest. */
|
||||
icon?: string;
|
||||
|
||||
tier?: string;
|
||||
source?: string;
|
||||
|
||||
+45
-8
@@ -3508,7 +3508,6 @@
|
||||
"sectionApiKeys": "API Keys",
|
||||
"sectionAuditLog": "Audit Log",
|
||||
"sectionSsl": "SSL / Let's Encrypt",
|
||||
"sectionTailscale": "Tailscale",
|
||||
"sectionTouchInput": "Touch Input",
|
||||
"touchEnabled": "Enable Touch Input",
|
||||
"touchEnabledDesc": "Allow one-finger touch scrolling in terminals",
|
||||
@@ -3667,11 +3666,6 @@
|
||||
"enableRemoteDesktopDesc": "RDP/VNC remote desktop",
|
||||
"enableRemoteDesktopDocsLink": "View docs",
|
||||
"guacdUrl": "guacd URL",
|
||||
"tailscaleApiKey": "Tailscale API Key",
|
||||
"tailscaleApiKeyDescription": "Used for device discovery in the host editor. Supports Tailscale (tskey-) and Headscale (hskey-) keys. Generate a key at tailscale.com/admin/settings/keys or from your Headscale instance.",
|
||||
"tailscaleApiKeyDocsLink": "View docs",
|
||||
"tailscaleApiBaseUrl": "API Base URL",
|
||||
"tailscaleApiBaseUrlDescription": "Optional. Point to a self-hosted Headscale instance instead of api.tailscale.com. Leave empty to use the Tailscale default.",
|
||||
"oidcDescription": "Configure OpenID Connect for SSO. Fields marked * are required.",
|
||||
"oidcDocsLink": "View docs",
|
||||
"oidcClientId": "Client ID",
|
||||
@@ -3822,8 +3816,6 @@
|
||||
"remoteDesktopSaved": "Remote Desktop settings saved",
|
||||
"remoteDesktopSaveFailed": "Failed to save Remote Desktop settings",
|
||||
"remoteDesktopUpdateFailed": "Failed to update Remote Desktop setting",
|
||||
"tailscaleSettingsSaved": "Tailscale settings saved",
|
||||
"tailscaleSettingsSaveFailed": "Failed to save Tailscale settings",
|
||||
"logLevelUpdateFailed": "Failed to update log level",
|
||||
"oidcSaved": "OIDC configuration saved",
|
||||
"oidcSaveFailed": "Failed to save OIDC config",
|
||||
@@ -5261,7 +5253,52 @@
|
||||
"title": "View tailnet devices",
|
||||
"description": "See the devices on the connected tailnet."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"group": {
|
||||
"api": "Tailscale API",
|
||||
"devices": "Tailnet"
|
||||
},
|
||||
"apiKey": {
|
||||
"label": "API key",
|
||||
"description": "Used to list the devices on your tailnet. Works with a Tailscale or Headscale key.",
|
||||
"placeholder": "tskey-api-... / hskey-api-..."
|
||||
},
|
||||
"apiBaseUrl": {
|
||||
"label": "API base URL",
|
||||
"description": "Leave empty for Tailscale. Set this to point at a Headscale instance.",
|
||||
"placeholder": "https://api.tailscale.com/api/v2"
|
||||
},
|
||||
"devices": {
|
||||
"label": "Device list",
|
||||
"checking": "Checking the tailnet...",
|
||||
"noKey": "No API key set yet.",
|
||||
"error": "Could not reach the tailnet. Check the API key.",
|
||||
"count": "{{count}} device reachable",
|
||||
"count_other": "{{count}} devices reachable",
|
||||
"refresh": "Check"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"bandYou": "You",
|
||||
"bandPlugins": "Plugins",
|
||||
"bandInstance": "Instance",
|
||||
"account": "Account",
|
||||
"secretSet": "Set",
|
||||
"secretNotSet": "Not set",
|
||||
"secretReplace": "Replace",
|
||||
"pluginGroupAdmin": "Server settings",
|
||||
"pluginGroupUser": "Your settings",
|
||||
"pluginNoSettings": "{{name}} has no settings you can change.",
|
||||
"pluginStoppedNotice": "{{name}} is installed but not running. These settings are saved but will not take effect.",
|
||||
"pluginSettingsSaved": "Settings saved",
|
||||
"pluginSettingsInvalid": "Some settings could not be saved. Check the highlighted fields.",
|
||||
"pluginSettingsSaveFailed": "Failed to save settings",
|
||||
"pluginSettingsLoadFailed": "Failed to load settings",
|
||||
"pluginsGroupLabel": "Plugins",
|
||||
"hostPluginNotRunning": "{{name}} is installed but not running. These settings are saved but will not take effect."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Host editor sections contributed by plugins.
|
||||
*
|
||||
* A plugin declares host-scope fields in its manifest and core draws them, so
|
||||
* a plugin cannot ship its own form styling and a section disappears when its
|
||||
* plugin does. The enable switch comes first and gates the rest, which is the
|
||||
* shape the hardcoded feature tabs already have.
|
||||
*
|
||||
* Nothing is rendered when no enabled plugin declares host settings, so the
|
||||
* group does not appear as an empty tab. Phase B fills it as each feature
|
||||
* moves its columns out of ssh_data.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
|
||||
import { SettingsFieldRow } from "./SettingsFields";
|
||||
import { isFieldActive } from "./settings-fields-util";
|
||||
|
||||
/** Values for every plugin on one host: { [pluginId]: { [key]: value } }. */
|
||||
export type HostPluginSettings = Record<string, Record<string, unknown>>;
|
||||
|
||||
/** Enabled plugins declaring host-scope settings. */
|
||||
export function usePluginHostSections(): PluginSummary[] {
|
||||
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getPlugins()
|
||||
.then((loaded) => {
|
||||
if (!cancelled) setPlugins(loaded);
|
||||
})
|
||||
.catch(() => {
|
||||
// Additive: the built-in host tabs do not depend on this.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
plugins.filter((plugin) => {
|
||||
if (!plugin.enabled) return false;
|
||||
const host = plugin.contributes?.settings?.host;
|
||||
return !!host && (host.fields.length > 0 || !!host.enableKey);
|
||||
}),
|
||||
[plugins],
|
||||
);
|
||||
}
|
||||
|
||||
export interface HostPluginSectionsProps {
|
||||
plugins: PluginSummary[];
|
||||
values: HostPluginSettings;
|
||||
setValue: (pluginId: string, key: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
export function HostPluginSections({
|
||||
plugins,
|
||||
values,
|
||||
setValue,
|
||||
}: HostPluginSectionsProps) {
|
||||
if (plugins.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{plugins.map((plugin) => (
|
||||
<HostPluginSection
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
values={values[plugin.id] ?? {}}
|
||||
setValue={(key, value) => setValue(plugin.id, key, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HostPluginSection({
|
||||
plugin,
|
||||
values,
|
||||
setValue,
|
||||
}: {
|
||||
plugin: PluginSummary;
|
||||
values: Record<string, unknown>;
|
||||
setValue: (key: string, value: unknown) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const host = plugin.contributes?.settings?.host;
|
||||
if (!host) return null;
|
||||
|
||||
const running = plugin.enabled && plugin.state !== "failed";
|
||||
const enableKey = host.enableKey;
|
||||
// With no enable switch the section is always on, which is what a plugin
|
||||
// declaring only plain fields means.
|
||||
const enabled = enableKey ? values[enableKey] === true : true;
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={plugin.name}
|
||||
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
|
||||
>
|
||||
{!running && (
|
||||
<div className="my-3 border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
|
||||
{t("settings.hostPluginNotRunning", { name: plugin.name })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enableKey && (
|
||||
<SettingsFieldRow
|
||||
pluginId={plugin.id}
|
||||
field={{
|
||||
key: enableKey,
|
||||
type: "boolean",
|
||||
labelKey: host.enableLabelKey,
|
||||
}}
|
||||
values={values}
|
||||
setValue={setValue}
|
||||
running={running}
|
||||
/>
|
||||
)}
|
||||
|
||||
{enabled &&
|
||||
host.fields
|
||||
.filter((field) => isFieldActive(field, values))
|
||||
.map((field) => (
|
||||
<SettingsFieldRow
|
||||
key={field.key}
|
||||
pluginId={plugin.id}
|
||||
field={field}
|
||||
values={values}
|
||||
setValue={setValue}
|
||||
running={running}
|
||||
/>
|
||||
))}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* One plugin's settings page, drawn from its manifest.
|
||||
*
|
||||
* The plugin declares groups and fields; Termix renders them with the same
|
||||
* SectionCard and SettingRow everything else uses. A plugin cannot ship its
|
||||
* own form styling and drift from the rest of the app, and a page disappears
|
||||
* cleanly when the plugin is uninstalled.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
import { pluginKey } from "@/lib/plugin-i18n";
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import {
|
||||
getPluginAdminSettings,
|
||||
getPluginUserSettings,
|
||||
updatePluginAdminSettings,
|
||||
updatePluginUserSettings,
|
||||
PluginSettingsValidationError,
|
||||
type PluginSettingsErrors,
|
||||
type PluginSettingsField,
|
||||
type PluginSummary,
|
||||
} from "@/api/plugins-api";
|
||||
import { SettingsFieldRow } from "./SettingsFields";
|
||||
import { isFieldActive } from "./settings-fields-util";
|
||||
|
||||
interface PluginSettingsPageProps {
|
||||
plugin: PluginSummary;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
type Scope = "admin" | "user";
|
||||
|
||||
export function PluginSettingsPage({
|
||||
plugin,
|
||||
isAdmin,
|
||||
}: PluginSettingsPageProps) {
|
||||
const { t } = useTranslation();
|
||||
const settings = plugin.contributes?.settings;
|
||||
const running = plugin.enabled && plugin.state !== "failed";
|
||||
|
||||
// Admin fields are not merely disabled for a non-admin: the values behind
|
||||
// them are install-wide configuration they have no business reading.
|
||||
const scopes = useMemo(() => {
|
||||
const entries: { scope: Scope; fields: PluginSettingsField[] }[] = [];
|
||||
if (isAdmin && (settings?.admin?.length ?? 0) > 0) {
|
||||
entries.push({ scope: "admin", fields: settings!.admin! });
|
||||
}
|
||||
if ((settings?.user?.length ?? 0) > 0) {
|
||||
entries.push({ scope: "user", fields: settings!.user! });
|
||||
}
|
||||
return entries;
|
||||
}, [settings, isAdmin]);
|
||||
|
||||
if (scopes.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-2.5">
|
||||
<p className="text-xs text-muted-foreground px-1">
|
||||
{t("settings.pluginNoSettings", { name: plugin.name })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-2.5">
|
||||
{!running && (
|
||||
<div className="border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
|
||||
{t("settings.pluginStoppedNotice", { name: plugin.name })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scopes.map(({ scope, fields }) => (
|
||||
<PluginScopeForm
|
||||
key={scope}
|
||||
plugin={plugin}
|
||||
scope={scope}
|
||||
fields={fields}
|
||||
running={running}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One scope's form.
|
||||
*
|
||||
* Admin and user settings are separate requests with separate permissions, so
|
||||
* they save separately rather than sharing one dirty state.
|
||||
*/
|
||||
function PluginScopeForm({
|
||||
plugin,
|
||||
scope,
|
||||
fields,
|
||||
running,
|
||||
}: {
|
||||
plugin: PluginSummary;
|
||||
scope: Scope;
|
||||
fields: PluginSettingsField[];
|
||||
running: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [errors, setErrors] = useState<PluginSettingsErrors>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const loaded =
|
||||
scope === "admin"
|
||||
? await getPluginAdminSettings(plugin.id)
|
||||
: await getPluginUserSettings(plugin.id);
|
||||
setValues(loaded);
|
||||
setErrors({});
|
||||
setDirty(false);
|
||||
} catch {
|
||||
toast.error(t("settings.pluginSettingsLoadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [plugin.id, scope, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const setValue = (key: string, value: unknown) => {
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
setErrors((current) => {
|
||||
if (!current[key]) return current;
|
||||
const next = { ...current };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved =
|
||||
scope === "admin"
|
||||
? await updatePluginAdminSettings(plugin.id, values)
|
||||
: await updatePluginUserSettings(plugin.id, values);
|
||||
setValues(saved);
|
||||
setErrors({});
|
||||
setDirty(false);
|
||||
toast.success(t("settings.pluginSettingsSaved"));
|
||||
} catch (error) {
|
||||
if (error instanceof PluginSettingsValidationError) {
|
||||
setErrors(error.errors);
|
||||
toast.error(t("settings.pluginSettingsInvalid"));
|
||||
} else {
|
||||
toast.error(t("settings.pluginSettingsSaveFailed"));
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fields keep their declared order within a group, and groups appear in the
|
||||
// order their first field does, so a manifest controls the whole layout.
|
||||
const groups = useMemo(() => {
|
||||
const ordered: {
|
||||
key: string | undefined;
|
||||
fields: PluginSettingsField[];
|
||||
}[] = [];
|
||||
for (const field of fields) {
|
||||
const existing = ordered.find((group) => group.key === field.group);
|
||||
if (existing) existing.fields.push(field);
|
||||
else ordered.push({ key: field.group, fields: [field] });
|
||||
}
|
||||
return ordered;
|
||||
}, [fields]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 py-4 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const icon = <PluginIcon name={plugin.icon} className="size-3.5" />;
|
||||
|
||||
return (
|
||||
<>
|
||||
{groups.map((group, index) => (
|
||||
<SectionCard
|
||||
key={group.key ?? `group-${index}`}
|
||||
title={
|
||||
group.key
|
||||
? t(pluginKey(plugin.id, group.key))
|
||||
: scope === "admin"
|
||||
? t("settings.pluginGroupAdmin")
|
||||
: t("settings.pluginGroupUser")
|
||||
}
|
||||
icon={icon}
|
||||
>
|
||||
{group.fields
|
||||
.filter((field) => isFieldActive(field, values))
|
||||
.map((field) => (
|
||||
<SettingsFieldRow
|
||||
key={field.key}
|
||||
pluginId={plugin.id}
|
||||
field={field}
|
||||
values={values}
|
||||
setValue={setValue}
|
||||
running={running}
|
||||
error={errors[field.key]}
|
||||
/>
|
||||
))}
|
||||
</SectionCard>
|
||||
))}
|
||||
|
||||
<div className="flex justify-end px-1">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving && <Loader2 className="size-3 animate-spin mr-1.5" />}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* One control per declared settings field type.
|
||||
*
|
||||
* A plugin supplies data; core draws the form. That is what keeps every
|
||||
* plugin's settings page looking like the rest of the app, and what makes a
|
||||
* page disappear cleanly when its plugin does.
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Textarea } from "@/components/textarea";
|
||||
import { Checkbox } from "@/components/checkbox";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import { Button } from "@/components/button";
|
||||
import { FakeSwitch, SettingRow } from "@/components/section-card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import { pluginKey } from "@/lib/plugin-i18n";
|
||||
import { isRedactedSecret, type PluginSettingsField } from "@/api/plugins-api";
|
||||
import { getSettingsComponent } from "./settings-components";
|
||||
|
||||
export interface SettingsFieldProps {
|
||||
pluginId: string;
|
||||
field: PluginSettingsField;
|
||||
values: Record<string, unknown>;
|
||||
setValue: (key: string, value: unknown) => void;
|
||||
/** Whether the owning plugin is running. */
|
||||
running: boolean;
|
||||
/** Message from a rejected save, shown under the control. */
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SettingsFieldRow({
|
||||
pluginId,
|
||||
field,
|
||||
values,
|
||||
setValue,
|
||||
running,
|
||||
error,
|
||||
disabled = false,
|
||||
}: SettingsFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const label = (key?: string) => (key ? t(pluginKey(pluginId, key)) : "");
|
||||
|
||||
// A custom field draws its own row: it has no single control to put in the
|
||||
// right-hand slot, and often no label of its own either.
|
||||
if (field.type === "custom") {
|
||||
const Component = getSettingsComponent(pluginId, field.component);
|
||||
if (!Component) return null;
|
||||
return (
|
||||
<Component
|
||||
pluginId={pluginId}
|
||||
values={values}
|
||||
setValue={setValue}
|
||||
running={running}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const value = values[field.key];
|
||||
const readOnly = disabled || !running;
|
||||
|
||||
const description = field.descriptionKey ? (
|
||||
<>
|
||||
{label(field.descriptionKey)}
|
||||
{error && <span className="block text-destructive mt-0.5">{error}</span>}
|
||||
</>
|
||||
) : error ? (
|
||||
<span className="text-destructive">{error}</span>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
label={label(field.labelKey)}
|
||||
description={description}
|
||||
badge={error ? t("common.error") : undefined}
|
||||
>
|
||||
<FieldControl
|
||||
pluginId={pluginId}
|
||||
field={field}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldControl({
|
||||
pluginId,
|
||||
field,
|
||||
value,
|
||||
setValue,
|
||||
readOnly,
|
||||
}: {
|
||||
pluginId: string;
|
||||
field: PluginSettingsField;
|
||||
value: unknown;
|
||||
setValue: (key: string, value: unknown) => void;
|
||||
readOnly: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const label = (key?: string) => (key ? t(pluginKey(pluginId, key)) : "");
|
||||
const placeholder = field.placeholderKey
|
||||
? label(field.placeholderKey)
|
||||
: undefined;
|
||||
|
||||
switch (field.type) {
|
||||
case "boolean":
|
||||
return (
|
||||
<FakeSwitch
|
||||
checked={value === true}
|
||||
disabled={readOnly}
|
||||
onChange={(next) => setValue(field.key, next)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
className="h-7 w-40 text-sm"
|
||||
disabled={readOnly}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
placeholder={placeholder}
|
||||
value={value === undefined || value === null ? "" : String(value)}
|
||||
onChange={(event) =>
|
||||
setValue(
|
||||
field.key,
|
||||
event.target.value === "" ? null : Number(event.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
disabled={readOnly}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onValueChange={(next) => setValue(field.key, next)}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-52 text-sm">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(field.options ?? []).map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{label(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
case "multiselect": {
|
||||
const selected = Array.isArray(value) ? (value as string[]) : [];
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 items-end">
|
||||
{(field.options ?? []).map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<span>{label(option.labelKey)}</span>
|
||||
<Checkbox
|
||||
disabled={readOnly}
|
||||
checked={selected.includes(option.value)}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue(
|
||||
field.key,
|
||||
checked
|
||||
? [...selected, option.value]
|
||||
: selected.filter((entry) => entry !== option.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "secret":
|
||||
return (
|
||||
<SecretControl
|
||||
field={field}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
readOnly={readOnly}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
|
||||
case "textarea":
|
||||
case "json":
|
||||
return (
|
||||
<Textarea
|
||||
className="w-64 text-sm font-mono min-h-16"
|
||||
disabled={readOnly}
|
||||
placeholder={placeholder}
|
||||
value={
|
||||
typeof value === "string"
|
||||
? value
|
||||
: value === undefined || value === null
|
||||
? ""
|
||||
: JSON.stringify(value, null, 2)
|
||||
}
|
||||
onChange={(event) => setValue(field.key, event.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
className="h-7 w-52 text-sm"
|
||||
disabled={readOnly}
|
||||
placeholder={placeholder}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => setValue(field.key, event.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A secret is never sent to the browser, so the control shows whether one is
|
||||
* stored and offers to replace or clear it. Leaving it untouched sends the
|
||||
* redacted marker straight back, which the server reads as "no change".
|
||||
*/
|
||||
function SecretControl({
|
||||
field,
|
||||
value,
|
||||
setValue,
|
||||
readOnly,
|
||||
placeholder,
|
||||
}: {
|
||||
field: PluginSettingsField;
|
||||
value: unknown;
|
||||
setValue: (key: string, value: unknown) => void;
|
||||
readOnly: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const redacted = isRedactedSecret(value);
|
||||
const stored = redacted && value.set;
|
||||
|
||||
if (redacted) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{stored ? t("settings.secretSet") : t("settings.secretNotSet")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={readOnly}
|
||||
onClick={() => setValue(field.key, "")}
|
||||
>
|
||||
{stored ? t("settings.secretReplace") : t("common.edit")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<PasswordInput
|
||||
className="h-7 w-52 text-sm"
|
||||
disabled={readOnly}
|
||||
placeholder={placeholder}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => setValue(field.key, event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={readOnly}
|
||||
onClick={() => setValue(field.key, { set: stored })}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Everything that configures the app rather than operating a server.
|
||||
*
|
||||
* Profile and admin were two separate rail panels, each a fixed accordion of
|
||||
* hardcoded sections. That is the wrong shape for the number of sections they
|
||||
* hold, and it left no place at all for a plugin to contribute one. This is a
|
||||
* single screen with a banded left nav, and a plugin's page is an entry in it
|
||||
* that appears and disappears with the plugin.
|
||||
*
|
||||
* Both rail buttons still open this, each on its own section, so the Navigation
|
||||
* visibility toggles and the habit of reaching for either one keep working.
|
||||
*/
|
||||
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronDown,
|
||||
Puzzle,
|
||||
Settings as SettingsIcon,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { Separator } from "@/components/separator";
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
|
||||
import { PluginSettingsPage } from "./PluginSettingsPage";
|
||||
import { registerLegacySettingsComponents } from "./legacy-settings-components";
|
||||
|
||||
const UserProfilePanel = lazy(() =>
|
||||
import("@/sidebar/UserProfilePanel").then((m) => ({
|
||||
default: m.UserProfilePanel,
|
||||
})),
|
||||
);
|
||||
const AdminSettingsPanel = lazy(() =>
|
||||
import("@/sidebar/AdminSettingsPanel").then((m) => ({
|
||||
default: m.AdminSettingsPanel,
|
||||
})),
|
||||
);
|
||||
|
||||
// Taken from the panels themselves: this screen only forwards them, so
|
||||
// restating their shapes here would be one more thing to keep in step.
|
||||
type ProfileProps = ComponentProps<typeof UserProfilePanel>;
|
||||
type AdminProps = ComponentProps<typeof AdminSettingsPanel>;
|
||||
|
||||
/** Built-in sections, plus "plugin:<id>" for a plugin's own settings page. */
|
||||
export type SettingsSectionId = "profile" | "admin" | `plugin:${string}`;
|
||||
|
||||
export interface SettingsScreenProps {
|
||||
/** Which page to land on. Both rail entries come in here. */
|
||||
initialSection?: SettingsSectionId;
|
||||
isAdmin?: boolean;
|
||||
/** Forwarded to the profile panel. */
|
||||
username?: ProfileProps["username"];
|
||||
onLogout?: ProfileProps["onLogout"];
|
||||
userPrefs?: ProfileProps["userPrefs"];
|
||||
onPrefsChange?: ProfileProps["onPrefsChange"];
|
||||
remoteSyncInitialServerUrl?: ProfileProps["remoteSyncInitialServerUrl"];
|
||||
remoteSyncReconnectRequested?: ProfileProps["remoteSyncReconnectRequested"];
|
||||
onRemoteSyncReconnectHandled?: ProfileProps["onRemoteSyncReconnectHandled"];
|
||||
/** Forwarded to the admin panel. */
|
||||
onEditingChange?: AdminProps["onEditingChange"];
|
||||
onOpenHostTab?: AdminProps["onOpenHostTab"];
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
id: SettingsSectionId;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
iconName?: string;
|
||||
}
|
||||
|
||||
export function SettingsScreen({
|
||||
initialSection = "profile",
|
||||
isAdmin = false,
|
||||
username,
|
||||
onLogout,
|
||||
userPrefs,
|
||||
onPrefsChange,
|
||||
remoteSyncInitialServerUrl,
|
||||
remoteSyncReconnectRequested,
|
||||
onRemoteSyncReconnectHandled,
|
||||
onEditingChange,
|
||||
onOpenHostTab,
|
||||
}: SettingsScreenProps) {
|
||||
const { t } = useTranslation();
|
||||
const [section, setSection] = useState<SettingsSectionId>(initialSection);
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
|
||||
|
||||
// Registered here rather than at module scope so a custom field's component
|
||||
// exists before any page that renders one. A7 moves this into the loader.
|
||||
useEffect(() => {
|
||||
registerLegacySettingsComponents();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getPlugins()
|
||||
.then((loaded) => {
|
||||
if (!cancelled) setPlugins(loaded);
|
||||
})
|
||||
.catch(() => {
|
||||
// The plugin band is additive: the core sections still work without it.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The rail can switch which entry opened the screen while it is mounted.
|
||||
useEffect(() => setSection(initialSection), [initialSection]);
|
||||
|
||||
/**
|
||||
* A plugin gets a page when it is enabled and declares settings this user
|
||||
* could actually change. A non-admin seeing a page of admin-only fields
|
||||
* would be a page they can read and never act on.
|
||||
*/
|
||||
const pluginPages = useMemo(
|
||||
() =>
|
||||
plugins.filter((plugin) => {
|
||||
if (!plugin.enabled) return false;
|
||||
const settings = plugin.contributes?.settings;
|
||||
if (!settings) return false;
|
||||
if ((settings.user?.length ?? 0) > 0) return true;
|
||||
return isAdmin && (settings.admin?.length ?? 0) > 0;
|
||||
}),
|
||||
[plugins, isAdmin],
|
||||
);
|
||||
|
||||
const bands = useMemo(() => {
|
||||
const result: { heading: string; items: NavItem[] }[] = [
|
||||
{
|
||||
heading: t("settings.bandYou"),
|
||||
items: [{ id: "profile", label: t("settings.account"), icon: User }],
|
||||
},
|
||||
];
|
||||
|
||||
if (pluginPages.length > 0) {
|
||||
result.push({
|
||||
heading: t("settings.bandPlugins"),
|
||||
items: pluginPages.map((plugin) => ({
|
||||
id: `plugin:${plugin.id}` as SettingsSectionId,
|
||||
label: plugin.name,
|
||||
icon: Puzzle,
|
||||
iconName: plugin.icon,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
result.push({
|
||||
heading: t("settings.bandInstance"),
|
||||
items: [{ id: "admin", label: t("nav.admin"), icon: SettingsIcon }],
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [t, pluginPages, isAdmin]);
|
||||
|
||||
const sections = bands.flatMap((band) => band.items);
|
||||
const active = sections.find((item) => item.id === section) ?? sections[0];
|
||||
|
||||
// A plugin page can vanish under you: the plugin is disabled from the admin
|
||||
// section in the same screen. Fall back rather than render an empty pane.
|
||||
useEffect(() => {
|
||||
if (
|
||||
section.startsWith("plugin:") &&
|
||||
!sections.some((s) => s.id === section)
|
||||
) {
|
||||
setSection("profile");
|
||||
}
|
||||
}, [section, sections]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
|
||||
<header className="flex h-12.5 shrink-0 flex-row items-center border-b border-border">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 px-3">
|
||||
<SettingsIcon className="size-4 shrink-0 text-accent-brand" />
|
||||
<span className="truncate text-base font-bold tracking-tight">
|
||||
{t("settings.title")}
|
||||
</span>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span className="hidden truncate text-[10px] font-semibold uppercase tracking-widest text-muted-foreground md:inline">
|
||||
{active?.label}
|
||||
</span>
|
||||
{/* The nav column does not fit beside the content on a phone, so
|
||||
there it collapses behind the section name. */}
|
||||
<button
|
||||
onClick={() => setNavOpen((open) => !open)}
|
||||
aria-expanded={navOpen}
|
||||
className="flex min-w-0 items-center gap-1 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground md:hidden"
|
||||
>
|
||||
<span className="truncate">{active?.label}</span>
|
||||
<ChevronDown
|
||||
className={`size-3 shrink-0 transition-transform ${navOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<nav
|
||||
className={`${navOpen ? "flex" : "hidden"} w-full shrink-0 flex-col gap-3 overflow-y-auto border-r border-border py-2.5 md:flex md:w-60`}
|
||||
>
|
||||
{bands.map((band) => (
|
||||
<div key={band.heading} className="flex flex-col">
|
||||
<span className="px-2.5 pb-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{band.heading}
|
||||
</span>
|
||||
{band.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const on = item.id === section;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setSection(item.id);
|
||||
setNavOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 border-l-2 py-2.5 pl-2 pr-2 text-left transition-colors md:py-1.5 ${
|
||||
on
|
||||
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
|
||||
: "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{item.iconName ? (
|
||||
<PluginIcon
|
||||
name={item.iconName}
|
||||
className="size-3.5 shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate text-xs font-medium">
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div
|
||||
className={`${navOpen ? "hidden" : ""} min-h-0 flex-1 overflow-y-auto md:block`}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
{section === "profile" && (
|
||||
<UserProfilePanel
|
||||
username={username}
|
||||
onLogout={onLogout}
|
||||
userPrefs={userPrefs}
|
||||
onPrefsChange={onPrefsChange}
|
||||
remoteSyncInitialServerUrl={remoteSyncInitialServerUrl}
|
||||
remoteSyncReconnectRequested={remoteSyncReconnectRequested}
|
||||
onRemoteSyncReconnectHandled={onRemoteSyncReconnectHandled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{section === "admin" && isAdmin && (
|
||||
<AdminSettingsPanel
|
||||
onEditingChange={onEditingChange}
|
||||
onOpenHostTab={onOpenHostTab}
|
||||
/>
|
||||
)}
|
||||
|
||||
{section.startsWith("plugin:") &&
|
||||
(() => {
|
||||
const plugin = pluginPages.find(
|
||||
(candidate) => `plugin:${candidate.id}` === section,
|
||||
);
|
||||
if (!plugin) return null;
|
||||
return <PluginSettingsPage plugin={plugin} isAdmin={isAdmin} />;
|
||||
})()}
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* The tailnet status shown on the Tailscale settings page.
|
||||
*
|
||||
* Confirms the saved key actually works, which is the question anyone has just
|
||||
* after entering one. Connecting to a device stays in the Tailscale rail panel;
|
||||
* a settings page is for configuring, not for operating.
|
||||
*
|
||||
* Registered by legacy-settings-components.ts until A7 lets the plugin's own
|
||||
* bundle register it.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { pluginKey } from "@/lib/plugin-i18n";
|
||||
import type { SettingsComponentProps } from "../settings-components";
|
||||
import { getTailscaleDevices } from "../../../../plugins/tailscale/src/frontend/tailscale-api";
|
||||
|
||||
type Status =
|
||||
| { kind: "loading" }
|
||||
| { kind: "no-key" }
|
||||
| { kind: "error" }
|
||||
| { kind: "ok"; count: number };
|
||||
|
||||
export function TailscaleDevicesStatus({ running }: SettingsComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<Status>({ kind: "loading" });
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!running) {
|
||||
setStatus({ kind: "no-key" });
|
||||
return;
|
||||
}
|
||||
setStatus({ kind: "loading" });
|
||||
try {
|
||||
const result = await getTailscaleDevices();
|
||||
if (!result.hasApiKey) {
|
||||
setStatus({ kind: "no-key" });
|
||||
return;
|
||||
}
|
||||
if (result.error) {
|
||||
setStatus({ kind: "error" });
|
||||
return;
|
||||
}
|
||||
setStatus({ kind: "ok", count: result.devices?.length ?? 0 });
|
||||
} catch {
|
||||
setStatus({ kind: "error" });
|
||||
}
|
||||
}, [running]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const label = (key: string) => t(pluginKey("tailscale", key));
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-3">
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<span className="text-sm font-medium leading-snug">
|
||||
{label("settings.devices.label")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{status.kind === "loading" && label("settings.devices.checking")}
|
||||
{status.kind === "no-key" && label("settings.devices.noKey")}
|
||||
{status.kind === "error" && label("settings.devices.error")}
|
||||
{status.kind === "ok" &&
|
||||
t(pluginKey("tailscale", "settings.devices.count"), {
|
||||
count: status.count,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs shrink-0"
|
||||
disabled={!running || status.kind === "loading"}
|
||||
onClick={() => void load()}
|
||||
>
|
||||
{status.kind === "loading" ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3" />
|
||||
)}
|
||||
<span className="ml-1.5">{label("settings.devices.refresh")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Registers the host editor's "Plugins" tab.
|
||||
*
|
||||
* Goes through the existing registerHostEditorTab seam rather than adding a
|
||||
* case to HostTabId, so the tab genuinely appears and disappears with the
|
||||
* plugins that fill it. Nothing is registered when no enabled plugin declares
|
||||
* host settings, which is what stops an empty tab from showing up.
|
||||
*
|
||||
* Re-run whenever the plugin list changes: enabling a plugin from the settings
|
||||
* screen has to put the tab there without a reload.
|
||||
*/
|
||||
|
||||
import { Puzzle } from "lucide-react";
|
||||
import {
|
||||
registerHostEditorTab,
|
||||
unregisterHostEditorTab,
|
||||
} from "@/sidebar/HostManagerTabs";
|
||||
import type { PluginSummary } from "@/api/plugins-api";
|
||||
import {
|
||||
HostPluginSections,
|
||||
type HostPluginSettings,
|
||||
} from "./HostPluginSections";
|
||||
|
||||
export const HOST_PLUGINS_TAB_ID = "plugins";
|
||||
|
||||
/** Only these two fields are read, so a test can pass a plain object. */
|
||||
interface HostFormLike {
|
||||
pluginSettings?: HostPluginSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or removes the tab to match the current plugin list.
|
||||
*
|
||||
* Returns whether the tab is now present, for a caller that needs to redirect
|
||||
* away from it when the last contributor goes.
|
||||
*/
|
||||
export function syncHostPluginsTab(plugins: PluginSummary[]): boolean {
|
||||
const contributors = plugins.filter((plugin) => {
|
||||
if (!plugin.enabled) return false;
|
||||
const host = plugin.contributes?.settings?.host;
|
||||
return !!host && (host.fields.length > 0 || !!host.enableKey);
|
||||
});
|
||||
|
||||
if (contributors.length === 0) {
|
||||
unregisterHostEditorTab(HOST_PLUGINS_TAB_ID);
|
||||
return false;
|
||||
}
|
||||
|
||||
registerHostEditorTab({
|
||||
id: HOST_PLUGINS_TAB_ID,
|
||||
labelKey: "settings.pluginsGroupLabel",
|
||||
icon: <Puzzle className="size-3" />,
|
||||
component: ({ form, setField }) => (
|
||||
<HostPluginSections
|
||||
plugins={contributors}
|
||||
values={(form as HostFormLike)?.pluginSettings ?? {}}
|
||||
setValue={(pluginId, key, value) => {
|
||||
const current = (form as HostFormLike)?.pluginSettings ?? {};
|
||||
setField("pluginSettings", {
|
||||
...current,
|
||||
[pluginId]: { ...(current[pluginId] ?? {}), [key]: value },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Core-side registration of plugin settings components.
|
||||
*
|
||||
* TEMPORARY. A `type: "custom"` settings field names a component the plugin's
|
||||
* own frontend bundle should register through `app.registerSettingsComponent`.
|
||||
* There is no frontend plugin loader yet, so core registers the handful that
|
||||
* exist, the same way pluginLoader.ts mirrors the first-party action
|
||||
* contributions.
|
||||
*
|
||||
* A7 deletes this file. Each plugin registers its own component from its
|
||||
* bundle, and nothing in core names a plugin id.
|
||||
*
|
||||
* Only register what is needed now. Everything else waits for its own step:
|
||||
* the AI providers list stays where it is until B18.
|
||||
*/
|
||||
|
||||
import { registerSettingsComponent } from "./settings-components.js";
|
||||
import { TailscaleDevicesStatus } from "./components/TailscaleDevicesStatus.js";
|
||||
|
||||
let registered = false;
|
||||
|
||||
export function registerLegacySettingsComponents(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
|
||||
registerSettingsComponent("tailscale", "devices", TailscaleDevicesStatus);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Components behind a `type: "custom"` settings field.
|
||||
*
|
||||
* Most settings are expressible as a schema, and those are rendered from the
|
||||
* manifest so a plugin cannot ship its own form styling. A few are not: a
|
||||
* device browser, a provider list. Those declare a component id here instead,
|
||||
* which keeps the escape hatch narrow and named rather than letting a plugin
|
||||
* hand core arbitrary markup.
|
||||
*
|
||||
* A7 lets a plugin's own frontend bundle register these through
|
||||
* `app.registerSettingsComponent`. Until then core registers them, in
|
||||
* legacy-settings-components.ts.
|
||||
*/
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
export interface SettingsComponentProps {
|
||||
pluginId: string;
|
||||
/** Current values for the scope this field sits in. */
|
||||
values: Record<string, unknown>;
|
||||
/** Writes one key in the same scope. */
|
||||
setValue: (key: string, value: unknown) => void;
|
||||
/** Whether the owning plugin is running. */
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
export type SettingsComponent = ComponentType<SettingsComponentProps>;
|
||||
|
||||
/** Keyed "<pluginId>:<componentId>" so two plugins may use the same name. */
|
||||
const registry = new Map<string, SettingsComponent>();
|
||||
|
||||
function registryKey(pluginId: string, componentId: string): string {
|
||||
return `${pluginId}:${componentId}`;
|
||||
}
|
||||
|
||||
export function registerSettingsComponent(
|
||||
pluginId: string,
|
||||
componentId: string,
|
||||
component: SettingsComponent,
|
||||
): () => void {
|
||||
const key = registryKey(pluginId, componentId);
|
||||
registry.set(key, component);
|
||||
return () => {
|
||||
if (registry.get(key) === component) registry.delete(key);
|
||||
};
|
||||
}
|
||||
|
||||
export function getSettingsComponent(
|
||||
pluginId: string,
|
||||
componentId: string | undefined,
|
||||
): SettingsComponent | undefined {
|
||||
if (!componentId) return undefined;
|
||||
return registry.get(registryKey(pluginId, componentId));
|
||||
}
|
||||
|
||||
export function unregisterSettingsComponents(pluginId: string): void {
|
||||
for (const key of [...registry.keys()]) {
|
||||
if (key.startsWith(`${pluginId}:`)) registry.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetSettingsComponents(): void {
|
||||
registry.clear();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { PluginSettingsField } from "@/api/plugins-api";
|
||||
|
||||
/**
|
||||
* Whether a field's `requires` gate is satisfied.
|
||||
*
|
||||
* Kept out of the component file so both the settings page and the host editor
|
||||
* section can import it without dragging a component along.
|
||||
*/
|
||||
export function isFieldActive(
|
||||
field: PluginSettingsField,
|
||||
values: Record<string, unknown>,
|
||||
): boolean {
|
||||
if (!field.requires) return true;
|
||||
return values[field.requires] === true;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { Bot } from "lucide-react";
|
||||
import { unregisterRailItem } from "@/sidebar/rail-items";
|
||||
import { unregisterTabComponent } from "@/shell/tabUtils";
|
||||
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
|
||||
import { syncHostPluginsTab } from "@/settings/host-plugins-tab";
|
||||
import {
|
||||
declareActionSlot,
|
||||
registerAction,
|
||||
@@ -122,6 +123,10 @@ export function applyPluginState(plugins: PluginSummary[]): void {
|
||||
pluginEnabled = nextEnabled;
|
||||
disabledTabTypes = nextDisabledTabs;
|
||||
applyFirstPartyActions(nextEnabled);
|
||||
|
||||
// The host editor's Plugins tab exists only while something fills it, so it
|
||||
// is re-synced whenever the plugin list changes rather than declared once.
|
||||
syncHostPluginsTab(plugins);
|
||||
}
|
||||
|
||||
export async function refreshPluginState(): Promise<PluginSummary[]> {
|
||||
|
||||
@@ -64,11 +64,6 @@ import {
|
||||
type TerminalImageStorageTestResult,
|
||||
type BrandingSettings,
|
||||
} from "@/api/settings-api";
|
||||
import {
|
||||
getTailscaleSettings,
|
||||
updateTailscaleSettings,
|
||||
} from "../../../plugins/tailscale/src/frontend/tailscale-api";
|
||||
import { AdminTailscaleSection } from "../../../plugins/tailscale/src/frontend/AdminTailscaleSection";
|
||||
import {
|
||||
getSessionSharingGloballyEnabled,
|
||||
updateSessionSharingGloballyEnabled,
|
||||
@@ -185,8 +180,6 @@ export function AdminSettingsPanel({
|
||||
const [guacEnabled, setGuacEnabled] = useState(false);
|
||||
const [guacUrl, setGuacUrl] = useState("guacd:4822");
|
||||
const [logLevel, setLogLevel] = useState("info");
|
||||
const [tailscaleApiKey, setTailscaleApiKey] = useState("");
|
||||
const [tailscaleApiBaseUrl, setTailscaleApiBaseUrl] = useState("");
|
||||
const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true);
|
||||
const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
|
||||
const [analyticsLocked, setAnalyticsLocked] = useState(false);
|
||||
@@ -413,7 +406,6 @@ export function AdminSettingsPanel({
|
||||
guac,
|
||||
oidcProv,
|
||||
oidcSilent,
|
||||
tailscale,
|
||||
cmdHistory,
|
||||
analytics,
|
||||
sessionSharingEnabled,
|
||||
@@ -435,7 +427,6 @@ export function AdminSettingsPanel({
|
||||
getGuacamoleSettings(),
|
||||
getOidcAutoProvision(),
|
||||
getOidcSilentLoginDefault(),
|
||||
getTailscaleSettings(),
|
||||
getCommandHistoryEnabled(),
|
||||
getAnalyticsEnabled(),
|
||||
getSessionSharingGloballyEnabled(),
|
||||
@@ -474,10 +465,6 @@ export function AdminSettingsPanel({
|
||||
setGuacEnabled(guac.value.enabled);
|
||||
setGuacUrl(guac.value.url || "guacd:4822");
|
||||
}
|
||||
if (tailscale.status === "fulfilled") {
|
||||
setTailscaleApiKey(tailscale.value.apiKey ?? "");
|
||||
setTailscaleApiBaseUrl(tailscale.value.apiBaseUrl ?? "");
|
||||
}
|
||||
if (cmdHistory.status === "fulfilled") {
|
||||
setCommandHistoryEnabled(cmdHistory.value.enabled);
|
||||
}
|
||||
@@ -886,15 +873,6 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveTailscaleApiKey() {
|
||||
try {
|
||||
await updateTailscaleSettings(tailscaleApiKey, tailscaleApiBaseUrl);
|
||||
toast.success(t("admin.tailscaleSettingsSaved"));
|
||||
} catch {
|
||||
toast.error(t("admin.tailscaleSettingsSaveFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveLogLevel(level: string) {
|
||||
setLogLevel(level);
|
||||
try {
|
||||
@@ -1341,16 +1319,6 @@ export function AdminSettingsPanel({
|
||||
handleSaveLogLevel={handleSaveLogLevel}
|
||||
/>
|
||||
|
||||
<AdminTailscaleSection
|
||||
open={openSections.has("tailscale")}
|
||||
onToggle={() => toggle("tailscale")}
|
||||
tailscaleApiKey={tailscaleApiKey}
|
||||
setTailscaleApiKey={setTailscaleApiKey}
|
||||
tailscaleApiBaseUrl={tailscaleApiBaseUrl}
|
||||
setTailscaleApiBaseUrl={setTailscaleApiBaseUrl}
|
||||
handleSaveTailscaleApiKey={handleSaveTailscaleApiKey}
|
||||
/>
|
||||
|
||||
<AdminSSOSection
|
||||
open={openSections.has("sso")}
|
||||
onToggle={() => toggle("sso")}
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
import type { Host, VaultProfile } from "@/types/ui-types";
|
||||
import type { SSHHost, TunnelStatus } from "@/types";
|
||||
import { useTabsSafe } from "@/shell/TabContext";
|
||||
import { updatePluginHostSettings } from "@/api/plugins-api";
|
||||
import {
|
||||
buildHostEditorPayload,
|
||||
createHostEditorForm,
|
||||
@@ -333,6 +334,29 @@ export function HostEditor({
|
||||
.finally(() => setTailscaleLoading(false));
|
||||
}, [form.authType]);
|
||||
|
||||
/**
|
||||
* Writes each plugin's host-scope values through its own route.
|
||||
*
|
||||
* One request per plugin rather than one per field, and a plugin that
|
||||
* rejects a value is reported without failing the host save that already
|
||||
* succeeded.
|
||||
*/
|
||||
const savePluginHostSettings = async (
|
||||
hostId: number,
|
||||
values: Record<string, Record<string, unknown>> | undefined,
|
||||
) => {
|
||||
if (!values || !Number.isInteger(hostId)) return;
|
||||
|
||||
for (const [pluginId, fields] of Object.entries(values)) {
|
||||
if (!fields || Object.keys(fields).length === 0) continue;
|
||||
try {
|
||||
await updatePluginHostSettings(pluginId, hostId, fields);
|
||||
} catch {
|
||||
toast.error(t("settings.pluginSettingsSaveFailed"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -350,6 +374,10 @@ export function HostEditor({
|
||||
? await updateSSHHost(Number(host.id), data)
|
||||
: await createSSHHost(data);
|
||||
}
|
||||
// After the host: a new one has no id to scope settings to until it
|
||||
// exists. A failure here must not claim the host itself failed to save.
|
||||
await savePluginHostSettings(Number(saved.id), form.pluginSettings);
|
||||
|
||||
toast.success(host ? t("hosts.hostUpdated") : t("hosts.hostCreated"));
|
||||
setPreviewTerminalTheme(null);
|
||||
onSave(saved);
|
||||
|
||||
@@ -342,6 +342,13 @@ export function createHostEditorForm(
|
||||
excludedMounts: [] as string[],
|
||||
monitoredMounts: [] as Array<{ path: string; label?: string }>,
|
||||
},
|
||||
|
||||
// Host-scope plugin settings, keyed by plugin id. Loaded with the host and
|
||||
// saved through the plugin's own scoped route, not in the host payload.
|
||||
pluginSettings: (host?.pluginSettings ?? {}) as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* The schema-driven settings form.
|
||||
*
|
||||
* A plugin declares fields and core draws them, so the thing worth proving is
|
||||
* that every declared type renders a usable control, reports what the user
|
||||
* typed, and that a secret's value never appears in the DOM.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { PluginSettingsField } from "@/api/plugins-api";
|
||||
import { SettingsFieldRow } from "@/settings/SettingsFields";
|
||||
import { isFieldActive } from "@/settings/settings-fields-util";
|
||||
import {
|
||||
registerSettingsComponent,
|
||||
resetSettingsComponents,
|
||||
} from "@/settings/settings-components";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
function renderField(
|
||||
field: PluginSettingsField,
|
||||
values: Record<string, unknown> = {},
|
||||
options: { running?: boolean } = {},
|
||||
) {
|
||||
const setValue = vi.fn();
|
||||
render(
|
||||
<SettingsFieldRow
|
||||
pluginId="sample"
|
||||
field={field}
|
||||
values={values}
|
||||
setValue={setValue}
|
||||
running={options.running ?? true}
|
||||
/>,
|
||||
);
|
||||
return { setValue };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetSettingsComponents();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("field types", () => {
|
||||
it("renders a boolean as a switch and reports the toggle", () => {
|
||||
const { setValue } = renderField(
|
||||
{ key: "enabled", type: "boolean", labelKey: "enabled.label" },
|
||||
{ enabled: false },
|
||||
);
|
||||
|
||||
const toggle = screen.getByRole("button");
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(setValue).toHaveBeenCalledWith("enabled", true);
|
||||
});
|
||||
|
||||
it("renders a string as a text box and reports what was typed", () => {
|
||||
const { setValue } = renderField(
|
||||
{ key: "baseUrl", type: "string", labelKey: "baseUrl.label" },
|
||||
{ baseUrl: "https://a" },
|
||||
);
|
||||
|
||||
const input = screen.getByDisplayValue("https://a");
|
||||
fireEvent.change(input, { target: { value: "https://b" } });
|
||||
|
||||
expect(setValue).toHaveBeenCalledWith("baseUrl", "https://b");
|
||||
});
|
||||
|
||||
it("renders a number with its declared bounds", () => {
|
||||
const { setValue } = renderField(
|
||||
{ key: "retries", type: "number", labelKey: "k", min: 0, max: 5 },
|
||||
{ retries: 2 },
|
||||
);
|
||||
|
||||
const input = screen.getByDisplayValue("2") as HTMLInputElement;
|
||||
expect(input.getAttribute("min")).toBe("0");
|
||||
expect(input.getAttribute("max")).toBe("5");
|
||||
|
||||
fireEvent.change(input, { target: { value: "4" } });
|
||||
expect(setValue).toHaveBeenCalledWith("retries", 4);
|
||||
});
|
||||
|
||||
it("clears a number to null rather than to zero", () => {
|
||||
const { setValue } = renderField(
|
||||
{ key: "retries", type: "number", labelKey: "k" },
|
||||
{ retries: 2 },
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue("2"), { target: { value: "" } });
|
||||
|
||||
expect(setValue).toHaveBeenCalledWith("retries", null);
|
||||
});
|
||||
|
||||
it("renders a textarea", () => {
|
||||
const { setValue } = renderField(
|
||||
{ key: "notes", type: "textarea", labelKey: "k" },
|
||||
{ notes: "hello" },
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue("hello"), {
|
||||
target: { value: "goodbye" },
|
||||
});
|
||||
|
||||
expect(setValue).toHaveBeenCalledWith("notes", "goodbye");
|
||||
});
|
||||
|
||||
it("renders json as editable text", () => {
|
||||
renderField({ key: "raw", type: "json", labelKey: "k" }, { raw: { a: 1 } });
|
||||
|
||||
expect(screen.getByDisplayValue(/"a": 1/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a multiselect option per choice and reports a selection", () => {
|
||||
const { setValue } = renderField(
|
||||
{
|
||||
key: "tags",
|
||||
type: "multiselect",
|
||||
labelKey: "k",
|
||||
options: [
|
||||
{ value: "a", labelKey: "opt.a" },
|
||||
{ value: "b", labelKey: "opt.b" },
|
||||
],
|
||||
},
|
||||
{ tags: ["a"] },
|
||||
);
|
||||
|
||||
const boxes = screen.getAllByRole("checkbox");
|
||||
expect(boxes).toHaveLength(2);
|
||||
|
||||
fireEvent.click(boxes[1]);
|
||||
expect(setValue).toHaveBeenCalledWith("tags", ["a", "b"]);
|
||||
});
|
||||
|
||||
it("removes a multiselect value when unticked", () => {
|
||||
const { setValue } = renderField(
|
||||
{
|
||||
key: "tags",
|
||||
type: "multiselect",
|
||||
labelKey: "k",
|
||||
options: [
|
||||
{ value: "a", labelKey: "opt.a" },
|
||||
{ value: "b", labelKey: "opt.b" },
|
||||
],
|
||||
},
|
||||
{ tags: ["a", "b"] },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByRole("checkbox")[0]);
|
||||
|
||||
expect(setValue).toHaveBeenCalledWith("tags", ["b"]);
|
||||
});
|
||||
|
||||
it("disables every control while the plugin is not running", () => {
|
||||
renderField(
|
||||
{ key: "baseUrl", type: "string", labelKey: "k" },
|
||||
{ baseUrl: "https://a" },
|
||||
{ running: false },
|
||||
);
|
||||
|
||||
expect(
|
||||
(screen.getByDisplayValue("https://a") as HTMLInputElement).disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("secrets", () => {
|
||||
const field: PluginSettingsField = {
|
||||
key: "apiKey",
|
||||
type: "secret",
|
||||
labelKey: "apiKey.label",
|
||||
};
|
||||
|
||||
it("shows a stored secret as set, without its value", () => {
|
||||
renderField(field, { apiKey: { set: true } });
|
||||
|
||||
expect(screen.getByText("settings.secretSet")).toBeTruthy();
|
||||
expect(document.body.textContent).not.toContain("tskey");
|
||||
});
|
||||
|
||||
it("shows an unset secret as not set", () => {
|
||||
renderField(field, { apiKey: { set: false } });
|
||||
|
||||
expect(screen.getByText("settings.secretNotSet")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("switches to an input when replacing", () => {
|
||||
const { setValue } = renderField(field, { apiKey: { set: true } });
|
||||
|
||||
fireEvent.click(screen.getByText("settings.secretReplace"));
|
||||
|
||||
expect(setValue).toHaveBeenCalledWith("apiKey", "");
|
||||
});
|
||||
|
||||
it("takes a new value once editing", () => {
|
||||
const { setValue } = renderField(field, { apiKey: "" });
|
||||
|
||||
// A password input has no textbox role, so it is found by its type.
|
||||
const input = document.querySelector(
|
||||
'input[type="password"]',
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "tskey-new" } });
|
||||
|
||||
expect(setValue).toHaveBeenCalledWith("apiKey", "tskey-new");
|
||||
});
|
||||
|
||||
it("restores the redacted marker on cancel, so saving changes nothing", () => {
|
||||
const { setValue } = renderField(field, { apiKey: "half-typed" });
|
||||
|
||||
fireEvent.click(screen.getByText("common.cancel"));
|
||||
|
||||
expect(setValue).toHaveBeenCalledWith("apiKey", { set: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom fields", () => {
|
||||
it("renders a registered component", () => {
|
||||
registerSettingsComponent("sample", "devices", () => (
|
||||
<div>custom-component-here</div>
|
||||
));
|
||||
|
||||
renderField({ key: "devices", type: "custom", component: "devices" });
|
||||
|
||||
expect(screen.getByText("custom-component-here")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders nothing when no component is registered", () => {
|
||||
const { container } = render(
|
||||
<SettingsFieldRow
|
||||
pluginId="sample"
|
||||
field={{ key: "devices", type: "custom", component: "missing" }}
|
||||
values={{}}
|
||||
setValue={vi.fn()}
|
||||
running
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("keeps two plugins' components apart", () => {
|
||||
registerSettingsComponent("sample", "shared", () => <div>mine</div>);
|
||||
registerSettingsComponent("other", "shared", () => <div>theirs</div>);
|
||||
|
||||
renderField({ key: "shared", type: "custom", component: "shared" });
|
||||
|
||||
expect(screen.getByText("mine")).toBeTruthy();
|
||||
expect(screen.queryByText("theirs")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("errors", () => {
|
||||
it("shows a per-field message from a rejected save", () => {
|
||||
render(
|
||||
<SettingsFieldRow
|
||||
pluginId="sample"
|
||||
field={{ key: "retries", type: "number", labelKey: "k" }}
|
||||
values={{ retries: 99 }}
|
||||
setValue={vi.fn()}
|
||||
running
|
||||
error='"retries" must be at most 5'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('"retries" must be at most 5')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFieldActive", () => {
|
||||
const gated: PluginSettingsField = {
|
||||
key: "socketPath",
|
||||
type: "string",
|
||||
labelKey: "k",
|
||||
requires: "enableDocker",
|
||||
};
|
||||
|
||||
it("is inactive while the named boolean is off", () => {
|
||||
expect(isFieldActive(gated, { enableDocker: false })).toBe(false);
|
||||
expect(isFieldActive(gated, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("is active once the named boolean is on", () => {
|
||||
expect(isFieldActive(gated, { enableDocker: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("is always active without a requires", () => {
|
||||
expect(isFieldActive({ key: "a", type: "string", labelKey: "k" }, {})).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* The one Settings screen.
|
||||
*
|
||||
* What matters here is the navigation: which bands appear for whom, that a
|
||||
* plugin's page shows up only when it has settings that user could change, and
|
||||
* that the nav collapses rather than disappearing on a phone.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { PluginSummary } from "@/api/plugins-api";
|
||||
|
||||
const { getPluginsMock } = vi.hoisted(() => ({ getPluginsMock: vi.fn() }));
|
||||
|
||||
vi.mock("@/api/plugins-api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/api/plugins-api")>()),
|
||||
getPlugins: getPluginsMock,
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// The two core panels are large and pull in most of the app; this screen only
|
||||
// has to decide which one to mount.
|
||||
vi.mock("@/sidebar/UserProfilePanel", () => ({
|
||||
UserProfilePanel: () => <div>profile-panel</div>,
|
||||
}));
|
||||
vi.mock("@/sidebar/AdminSettingsPanel", () => ({
|
||||
AdminSettingsPanel: () => <div>admin-panel</div>,
|
||||
}));
|
||||
vi.mock("@/settings/legacy-settings-components", () => ({
|
||||
registerLegacySettingsComponents: vi.fn(),
|
||||
}));
|
||||
|
||||
const { SettingsScreen } = await import("@/settings/SettingsScreen");
|
||||
|
||||
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
||||
return {
|
||||
id: "tailscale",
|
||||
name: "Tailscale",
|
||||
version: "1.0.0",
|
||||
enabled: true,
|
||||
state: "active",
|
||||
contributes: {
|
||||
settings: { admin: [{ key: "apiKey", type: "secret", labelKey: "k" }] },
|
||||
},
|
||||
...overrides,
|
||||
} as PluginSummary;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getPluginsMock.mockReset();
|
||||
getPluginsMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("navigation bands", () => {
|
||||
it("always shows the You band", async () => {
|
||||
render(<SettingsScreen />);
|
||||
|
||||
expect(await screen.findByText("settings.bandYou")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the Instance band only to an admin", async () => {
|
||||
const { unmount } = render(<SettingsScreen isAdmin={false} />);
|
||||
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
|
||||
expect(screen.queryByText("settings.bandInstance")).toBeNull();
|
||||
unmount();
|
||||
|
||||
render(<SettingsScreen isAdmin />);
|
||||
expect(await screen.findByText("settings.bandInstance")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows no Plugins band when nothing declares settings", async () => {
|
||||
render(<SettingsScreen isAdmin />);
|
||||
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
|
||||
|
||||
expect(screen.queryByText("settings.bandPlugins")).toBeNull();
|
||||
});
|
||||
|
||||
it("lists a plugin with admin settings for an admin", async () => {
|
||||
getPluginsMock.mockResolvedValue([plugin()]);
|
||||
|
||||
render(<SettingsScreen isAdmin />);
|
||||
|
||||
expect(await screen.findByText("settings.bandPlugins")).toBeTruthy();
|
||||
expect(screen.getByText("Tailscale")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides an admin-only plugin page from a non-admin", async () => {
|
||||
getPluginsMock.mockResolvedValue([plugin()]);
|
||||
|
||||
render(<SettingsScreen isAdmin={false} />);
|
||||
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
|
||||
|
||||
expect(screen.queryByText("Tailscale")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a plugin with user settings to everyone", async () => {
|
||||
getPluginsMock.mockResolvedValue([
|
||||
plugin({
|
||||
contributes: {
|
||||
settings: { user: [{ key: "theme", type: "string", labelKey: "k" }] },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<SettingsScreen isAdmin={false} />);
|
||||
|
||||
expect(await screen.findByText("Tailscale")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides a disabled plugin's page", async () => {
|
||||
getPluginsMock.mockResolvedValue([plugin({ enabled: false })]);
|
||||
|
||||
render(<SettingsScreen isAdmin />);
|
||||
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
|
||||
|
||||
expect(screen.queryByText("Tailscale")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps working when the plugin list cannot be loaded", async () => {
|
||||
getPluginsMock.mockRejectedValue(new Error("offline"));
|
||||
|
||||
render(<SettingsScreen isAdmin />);
|
||||
|
||||
expect(await screen.findByText("settings.bandYou")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("initial section", () => {
|
||||
it("lands on the profile panel by default", async () => {
|
||||
render(<SettingsScreen />);
|
||||
|
||||
expect(await screen.findByText("profile-panel")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lands on the admin panel when opened from the admin rail entry", async () => {
|
||||
render(<SettingsScreen isAdmin initialSection="admin" />);
|
||||
|
||||
expect(await screen.findByText("admin-panel")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not mount the admin panel for a non-admin", async () => {
|
||||
render(<SettingsScreen isAdmin={false} initialSection="admin" />);
|
||||
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
|
||||
|
||||
expect(screen.queryByText("admin-panel")).toBeNull();
|
||||
});
|
||||
|
||||
it("switches section when the rail entry changes", async () => {
|
||||
const { rerender } = render(
|
||||
<SettingsScreen isAdmin initialSection="profile" />,
|
||||
);
|
||||
expect(await screen.findByText("profile-panel")).toBeTruthy();
|
||||
|
||||
rerender(<SettingsScreen isAdmin initialSection="admin" />);
|
||||
|
||||
expect(await screen.findByText("admin-panel")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("phone layout", () => {
|
||||
it("offers a disclosure button for the collapsed nav", async () => {
|
||||
render(<SettingsScreen />);
|
||||
await screen.findByText("settings.bandYou");
|
||||
|
||||
const toggle = document.querySelector("button[aria-expanded]");
|
||||
expect(toggle).toBeTruthy();
|
||||
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("hides the nav below md and shows it from md up", async () => {
|
||||
render(<SettingsScreen />);
|
||||
await screen.findByText("settings.bandYou");
|
||||
|
||||
const nav = document.querySelector("nav")!;
|
||||
expect(nav.className).toContain("hidden");
|
||||
expect(nav.className).toContain("md:flex");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* The host editor's Plugins group.
|
||||
*
|
||||
* No bundled plugin declares host settings yet, so a fixture stands in for one.
|
||||
* The rule worth protecting is the empty case: with nothing to show, the tab
|
||||
* must not be registered at all rather than appearing blank.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { PluginSummary } from "@/api/plugins-api";
|
||||
import {
|
||||
HOST_PLUGINS_TAB_ID,
|
||||
syncHostPluginsTab,
|
||||
} from "@/settings/host-plugins-tab";
|
||||
import {
|
||||
getRegisteredHostEditorTab,
|
||||
unregisterHostEditorTab,
|
||||
} from "@/sidebar/HostManagerTabs";
|
||||
import { HostPluginSections } from "@/settings/HostPluginSections";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
||||
return {
|
||||
id: "docker",
|
||||
name: "Docker",
|
||||
version: "1.0.0",
|
||||
enabled: true,
|
||||
state: "active",
|
||||
icon: "Box",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
enableKey: "enableDocker",
|
||||
enableLabelKey: "enableDocker.label",
|
||||
fields: [
|
||||
{ key: "socketPath", type: "string", labelKey: "socket.label" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
} as PluginSummary;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
unregisterHostEditorTab(HOST_PLUGINS_TAB_ID);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
unregisterHostEditorTab(HOST_PLUGINS_TAB_ID);
|
||||
});
|
||||
|
||||
describe("syncHostPluginsTab", () => {
|
||||
it("registers the tab when a plugin contributes host settings", () => {
|
||||
expect(syncHostPluginsTab([plugin()])).toBe(true);
|
||||
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeDefined();
|
||||
});
|
||||
|
||||
it("registers nothing when no plugin contributes", () => {
|
||||
expect(syncHostPluginsTab([])).toBe(false);
|
||||
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores a plugin with no host settings", () => {
|
||||
const other = plugin({
|
||||
id: "ai",
|
||||
contributes: { settings: { admin: [] } },
|
||||
});
|
||||
|
||||
expect(syncHostPluginsTab([other])).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores a disabled plugin", () => {
|
||||
expect(syncHostPluginsTab([plugin({ enabled: false })])).toBe(false);
|
||||
});
|
||||
|
||||
it("removes the tab once the last contributor goes", () => {
|
||||
syncHostPluginsTab([plugin()]);
|
||||
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeDefined();
|
||||
|
||||
syncHostPluginsTab([plugin({ enabled: false })]);
|
||||
|
||||
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("registers for a plugin declaring only an enable switch", () => {
|
||||
const bare = plugin({
|
||||
contributes: {
|
||||
settings: {
|
||||
host: { enableKey: "enableThing", enableLabelKey: "k", fields: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(syncHostPluginsTab([bare])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HostPluginSections", () => {
|
||||
it("shows the enable switch and hides the fields until it is on", () => {
|
||||
const setValue = vi.fn();
|
||||
render(
|
||||
<HostPluginSections
|
||||
plugins={[plugin()]}
|
||||
values={{ docker: { enableDocker: false } }}
|
||||
setValue={setValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("plugins.docker.enableDocker.label")).toBeTruthy();
|
||||
expect(screen.queryByText("plugins.docker.socket.label")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the fields once the enable switch is on", () => {
|
||||
render(
|
||||
<HostPluginSections
|
||||
plugins={[plugin()]}
|
||||
values={{ docker: { enableDocker: true } }}
|
||||
setValue={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("plugins.docker.socket.label")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reports a change against the right plugin", () => {
|
||||
const setValue = vi.fn();
|
||||
render(
|
||||
<HostPluginSections
|
||||
plugins={[plugin()]}
|
||||
values={{ docker: { enableDocker: false } }}
|
||||
setValue={setValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button")[0]);
|
||||
|
||||
expect(setValue).toHaveBeenCalledWith("docker", "enableDocker", true);
|
||||
});
|
||||
|
||||
it("warns when the plugin is installed but not running", () => {
|
||||
render(
|
||||
<HostPluginSections
|
||||
plugins={[plugin({ state: "failed" })]}
|
||||
values={{ docker: {} }}
|
||||
setValue={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("settings.hostPluginNotRunning")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders nothing at all with no contributing plugins", () => {
|
||||
const { container } = render(
|
||||
<HostPluginSections plugins={[]} values={{}} setValue={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("keeps two plugins' values apart", () => {
|
||||
const vault = plugin({
|
||||
id: "vault",
|
||||
name: "Vault",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
fields: [{ key: "role", type: "string", labelKey: "role.label" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<HostPluginSections
|
||||
plugins={[plugin(), vault]}
|
||||
values={{
|
||||
docker: { enableDocker: true, socketPath: "/var/run/docker.sock" },
|
||||
vault: { role: "web" },
|
||||
}}
|
||||
setValue={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByDisplayValue("/var/run/docker.sock")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("web")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user