mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning
This commit is contained in:
1 parent
c21bca8cf0
commit
fbe736bc20
70 files changed
+1117
-1139
No files matched your search
+29
-13
@@ -86,11 +86,14 @@ const MacrosPanel = lazy(() =>
|
||||
import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })),
|
||||
);
|
||||
|
||||
// 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 UserProfilePanel = lazy(() =>
|
||||
import("@/sidebar/UserProfilePanel").then((m) => ({
|
||||
default: m.UserProfilePanel,
|
||||
})),
|
||||
);
|
||||
const AdminSettingsPanel = lazy(() =>
|
||||
import("@/sidebar/AdminSettingsPanel").then((m) => ({
|
||||
default: m.AdminSettingsPanel,
|
||||
})),
|
||||
);
|
||||
const CredentialsPanel = lazy(() =>
|
||||
@@ -2269,6 +2272,17 @@ export function AppShell({
|
||||
setRailView((prev) => (prev === id ? "hosts" : prev));
|
||||
setRightRailView((prev) => (prev === id ? null : prev));
|
||||
},
|
||||
openHostEditor: (draft) => {
|
||||
setSidebarOpen(true);
|
||||
setRailView("hosts");
|
||||
setTimeout(
|
||||
() =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("host-manager:add-host", { detail: draft }),
|
||||
),
|
||||
0,
|
||||
);
|
||||
},
|
||||
saveQuickConnect: saveQuickConnectHost,
|
||||
};
|
||||
|
||||
@@ -2480,14 +2494,9 @@ export function AppShell({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(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}
|
||||
{railView === "user-profile" && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<UserProfilePanel
|
||||
username={username}
|
||||
onLogout={onLogout}
|
||||
userPrefs={userPrefs}
|
||||
@@ -2499,6 +2508,13 @@ 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);
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface PluginHostSettingsContribution {
|
||||
enableKey?: string;
|
||||
enableLabelKey?: string;
|
||||
enableDefault?: boolean;
|
||||
editorGroup?: "top" | "ssh";
|
||||
editorOrder?: number;
|
||||
fields: PluginSettingsField[];
|
||||
}
|
||||
|
||||
@@ -246,30 +248,3 @@ export async function getPlugins(): Promise<PluginSummary[]> {
|
||||
const response = await rbacApi.get("/plugins");
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
}
|
||||
|
||||
export async function setPluginEnabled(
|
||||
pluginId: string,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
await rbacApi.patch(`/plugins/${encodeURIComponent(pluginId)}/state`, {
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export async function grantPluginCapability(
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
): Promise<void> {
|
||||
await rbacApi.post(`/plugins/${encodeURIComponent(pluginId)}/grants`, {
|
||||
capability,
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokePluginCapability(
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
): Promise<void> {
|
||||
await rbacApi.delete(
|
||||
`/plugins/${encodeURIComponent(pluginId)}/grants/${encodeURIComponent(capability)}`,
|
||||
);
|
||||
}
|
||||
+15
-40
@@ -1067,8 +1067,8 @@
|
||||
"filterAuthAgent": "SSH Agent",
|
||||
"filterAuthVault": "Vault",
|
||||
"filterAuthTailscale": "Tailscale",
|
||||
"authTypeNeedsPlugin": "This host uses {{type}}, which needs the {{plugin}} plugin. Turn it on to connect.",
|
||||
"authTypeUnknown": "This host uses {{type}}, which no enabled plugin provides.",
|
||||
"authTypeNeedsPlugin": "This host uses {{type}}, which needs {{plugin}}. It is turned off on this server.",
|
||||
"authTypeUnknown": "This host uses {{type}}, which is not available on this server.",
|
||||
"authFieldsAfterSave": "Save the host first, then fill in these fields.",
|
||||
"enablePluginFeature": "Enable {{name}}",
|
||||
"disablePluginFeature": "Disable {{name}}",
|
||||
@@ -1099,7 +1099,7 @@
|
||||
"navigate": "Navigate",
|
||||
"select": "Select",
|
||||
"toggleWith": "Toggle with",
|
||||
"pluginActions": "From plugins"
|
||||
"pluginActions": "More actions"
|
||||
},
|
||||
"splitScreen": {
|
||||
"paneEmpty": "Pane {{index}} - empty",
|
||||
@@ -1527,7 +1527,7 @@
|
||||
"brandingLogoInvalid": "Invalid image. Use a PNG, JPEG or WEBP file up to 750 KB.",
|
||||
"brandingSaved": "Branding settings saved",
|
||||
"brandingSaveFailed": "Failed to save branding settings",
|
||||
"sslDescription": "The certificate Termix serves over HTTPS. Upload your own here, or let the ACME Certificates plugin get and renew one from Let's Encrypt.",
|
||||
"sslDescription": "The certificate Termix serves over HTTPS. Upload your own here, or get and renew one from Let's Encrypt in the ACME Certificates section.",
|
||||
"sslDocsLink": "View SSL docs",
|
||||
"sslCertStatus": "Certificate Status",
|
||||
"sslCertStatusNone": "No certificate",
|
||||
@@ -1811,7 +1811,7 @@
|
||||
"saveError": "Failed to save role permissions",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"pluginDisabled": "Plugin disabled",
|
||||
"pluginDisabled": "Feature disabled",
|
||||
"groups": {
|
||||
"hosts": "Hosts",
|
||||
"credentials": "Credentials",
|
||||
@@ -1821,30 +1821,15 @@
|
||||
"notificationPrivateEndpoints": "Allowed private notification hosts",
|
||||
"notificationPrivateEndpointsDesc": "Exact private hosts that notification channels may contact. Separate them with commas.",
|
||||
"updateNotificationEndpointsFailed": "Failed to update notification endpoint allowlist",
|
||||
"plugins": "Plugins",
|
||||
"pluginsNone": "No plugins installed.",
|
||||
"pluginsLoadFailed": "Failed to load plugins",
|
||||
"pluginToggleFailed": "Failed to change the plugin",
|
||||
"pluginBuiltIn": "BUILT IN",
|
||||
"pluginEnabled": "{{name}} enabled",
|
||||
"pluginDisabled": "{{name}} disabled",
|
||||
"pluginPermissions": "Permissions",
|
||||
"pluginPermissionsNone": "This plugin does not declare any capabilities that need to be granted.",
|
||||
"pluginPublicRoutes": "Public routes",
|
||||
"pluginPublicRoutesDescription": "Anyone can reach these without signing in. The plugin checks access on its own.",
|
||||
"pluginPermissionGranted": "Granted",
|
||||
"pluginPermissionNotGranted": "Not granted",
|
||||
"pluginGrantFailed": "Failed to grant the capability",
|
||||
"pluginRevokeFailed": "Failed to revoke the capability",
|
||||
"secondFactorsSectionTitle": "Second factors",
|
||||
"secondFactorsNone": "No second factors enrolled",
|
||||
"secondFactorUnavailable": "The {{plugin}} plugin is off",
|
||||
"secondFactorUnavailable": "Unavailable",
|
||||
"resetSecondFactors": "Reset second factors",
|
||||
"resetSecondFactorsConfirm": "Remove every second factor for {{username}}? They can sign in with their password or SSO and enrol again.",
|
||||
"secondFactorsResetSuccess": "Second factors reset",
|
||||
"secondFactorsResetFailed": "Failed to reset second factors",
|
||||
"passwordLoginForced": "Password login is turned off, but no other login method is enabled, so it stays on for now. Nobody gets locked out.",
|
||||
"sslNotRenewed": "Nothing renews this certificate. It expires {{date}}. Turn on the ACME Certificates plugin and its automatic renewal, or upload a new one before then.",
|
||||
"sslNotRenewed": "Nothing renews this certificate. It expires {{date}}. Turn on automatic renewal in the ACME Certificates section, or upload a new one before then.",
|
||||
"sslSelfSigned": "Self-signed certificate made by Termix. Browsers will warn about it.",
|
||||
"sslRenewedBy": "Renewed automatically by {{plugin}}",
|
||||
"sslIssuer": "Issued by {{issuer}}",
|
||||
@@ -2506,36 +2491,26 @@
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
"needsPlugin": "This needs the {{name}} plugin",
|
||||
"needsUnknownPlugin": "This needs a plugin that is not installed",
|
||||
"disabledHint": "An admin can turn it on in Settings under Plugins.",
|
||||
"missingHint": "It was added by a plugin that has since been removed.",
|
||||
"failed": "The {{name}} plugin could not start",
|
||||
"failedHint": "An admin can see why in Settings under Plugins.",
|
||||
"needsPlugin": "{{name}} is not available",
|
||||
"needsUnknownPlugin": "This feature is not available",
|
||||
"disabledHint": "It is turned off on this server.",
|
||||
"missingHint": "The feature it came from has been removed.",
|
||||
"failed": "{{name}} could not start",
|
||||
"failedHint": "An admin can check the server logs for details.",
|
||||
"loading": "Loading {{name}}",
|
||||
"crashed": "Something went wrong in the {{name}} plugin",
|
||||
"crashed": "Something went wrong in {{name}}",
|
||||
"retry": "Try again"
|
||||
}
|
||||
},
|
||||
"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."
|
||||
"featureUnavailable": "{{name}} is unavailable right now. These settings are saved but will not take effect."
|
||||
},
|
||||
"managerCard": {
|
||||
"refresh": "Refresh",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Puzzle } from "lucide-react";
|
||||
import { CircleSlash } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePluginStore } from "./plugin-store";
|
||||
import { unregisteredViewStatus, type ViewKind } from "./view-ownership";
|
||||
@@ -55,7 +55,7 @@ export function PluginViewPlaceholder({
|
||||
data-status={status}
|
||||
className={`flex h-full w-full flex-col items-center justify-center gap-2 text-center ${compact ? "p-3" : "p-6"}`}
|
||||
>
|
||||
<Puzzle className="size-6 text-muted-foreground/40" />
|
||||
<CircleSlash className="size-6 text-muted-foreground/40" />
|
||||
<span className="text-sm font-semibold text-muted-foreground">
|
||||
{title}
|
||||
</span>
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
setPluginSummaries,
|
||||
} from "./plugin-store";
|
||||
import { workspaceFrontends, workspaceLocales } from "./workspace-plugins";
|
||||
import { syncHostPluginsTab } from "@/settings/host-plugins-tab";
|
||||
import { syncHostFeatureTabs } from "@/settings/host-feature-tabs";
|
||||
|
||||
/**
|
||||
* Loads plugin frontends into the running shell.
|
||||
@@ -273,8 +273,6 @@ async function loadNamespaces(summaries: PluginSummary[]): Promise<void> {
|
||||
|
||||
async function reconcile(summaries: PluginSummary[]): Promise<void> {
|
||||
setPluginSummaries(summaries);
|
||||
// The host editor's Plugins tab exists only while something fills it.
|
||||
syncHostPluginsTab(summaries);
|
||||
await loadNamespaces(summaries);
|
||||
|
||||
const { order, blocked } = orderForActivation(summaries);
|
||||
@@ -301,6 +299,9 @@ async function reconcile(summaries: PluginSummary[]): Promise<void> {
|
||||
}
|
||||
|
||||
await activateConcurrently(order);
|
||||
// After activation, so a plugin that registered its own host editor
|
||||
// section is not given a second, generated one.
|
||||
syncHostFeatureTabs(summaries);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -87,6 +87,7 @@ export const shell: TabShellCallbacks = {
|
||||
renameTab: (...args) => callbacks?.renameTab(...args),
|
||||
openRailView: (...args) => callbacks?.openRailView(...args),
|
||||
closeRailView: (...args) => callbacks?.closeRailView(...args),
|
||||
openHostEditor: (...args) => callbacks?.openHostEditor?.(...args),
|
||||
saveQuickConnect: (...args) =>
|
||||
callbacks?.saveQuickConnect?.(...args) ?? Promise.resolve(),
|
||||
};
|
||||
|
||||
@@ -80,6 +80,7 @@ function recordingShell(calls: ShellCall[]): TabShellCallbacks {
|
||||
renameTab: record("renameTab"),
|
||||
openRailView: record("openRailView"),
|
||||
closeRailView: record("closeRailView"),
|
||||
openHostEditor: record("openHostEditor"),
|
||||
saveQuickConnect: async (...args) => {
|
||||
calls.push({ method: "saveQuickConnect", args });
|
||||
},
|
||||
|
||||
+108
-106
@@ -1,10 +1,7 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
/**
|
||||
* 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.
|
||||
* Manifest-declared settings shown as ordinary sections in the profile and
|
||||
* admin panels, one per feature, named after it.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
@@ -13,9 +10,11 @@ import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
import { AccordionSection } from "@/sidebar/AdminSettingsShared";
|
||||
import { pluginKey } from "@/lib/plugin-i18n";
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import {
|
||||
getPlugins,
|
||||
getPluginAdminSettings,
|
||||
getPluginUserSettings,
|
||||
updatePluginAdminSettings,
|
||||
@@ -26,83 +25,82 @@ import {
|
||||
type PluginSummary,
|
||||
} from "@/api/plugins-api";
|
||||
import { SettingsFieldRow } from "./SettingsFields";
|
||||
import { isFieldActive } from "./settings-fields-util";
|
||||
import { hasVisibleFields, isFieldActive } from "./settings-fields-util";
|
||||
|
||||
interface PluginSettingsPageProps {
|
||||
plugin: PluginSummary;
|
||||
isAdmin: boolean;
|
||||
export type FeatureSettingsScope = "admin" | "user";
|
||||
|
||||
export type FeatureSectionId = `feature:${string}`;
|
||||
|
||||
export function featureSectionId(pluginId: string): FeatureSectionId {
|
||||
return `feature:${pluginId}`;
|
||||
}
|
||||
|
||||
type Scope = "admin" | "user";
|
||||
/** Enabled features with fields in this scope, sorted by name. */
|
||||
export function useFeatureSettings(
|
||||
scope: FeatureSettingsScope,
|
||||
): PluginSummary[] {
|
||||
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
|
||||
|
||||
export function PluginSettingsPage({
|
||||
plugin,
|
||||
isAdmin,
|
||||
}: PluginSettingsPageProps) {
|
||||
const { t } = useTranslation();
|
||||
const settings = plugin.contributes?.settings;
|
||||
const running = plugin.enabled && plugin.state !== "failed";
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getPlugins()
|
||||
.then((loaded) => {
|
||||
if (!cancelled) setPlugins(loaded);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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>
|
||||
return useMemo(
|
||||
() =>
|
||||
plugins
|
||||
.filter(
|
||||
(plugin) =>
|
||||
plugin.enabled &&
|
||||
hasVisibleFields(plugin.contributes?.settings?.[scope] ?? []),
|
||||
)
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[plugins, scope],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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({
|
||||
export function FeatureSettingsSection({
|
||||
plugin,
|
||||
scope,
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
plugin: PluginSummary;
|
||||
scope: FeatureSettingsScope;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<AccordionSection
|
||||
label={plugin.name}
|
||||
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<FeatureSettingsForm
|
||||
plugin={plugin}
|
||||
scope={scope}
|
||||
fields={plugin.contributes?.settings?.[scope] ?? []}
|
||||
/>
|
||||
</AccordionSection>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureSettingsForm({
|
||||
plugin,
|
||||
scope,
|
||||
fields,
|
||||
running,
|
||||
}: {
|
||||
plugin: PluginSummary;
|
||||
scope: Scope;
|
||||
scope: FeatureSettingsScope;
|
||||
fields: PluginSettingsField[];
|
||||
running: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
@@ -110,6 +108,7 @@ function PluginScopeForm({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const running = plugin.enabled && plugin.state !== "failed";
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -166,13 +165,9 @@ function PluginScopeForm({
|
||||
}
|
||||
};
|
||||
|
||||
// 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.
|
||||
// Groups appear in the order of their first field, so the manifest sets the layout.
|
||||
const groups = useMemo(() => {
|
||||
const ordered: {
|
||||
key: string | undefined;
|
||||
fields: PluginSettingsField[];
|
||||
}[] = [];
|
||||
const ordered: { key?: string; fields: PluginSettingsField[] }[] = [];
|
||||
for (const field of fields) {
|
||||
const existing = ordered.find((group) => group.key === field.group);
|
||||
if (existing) existing.fields.push(field);
|
||||
@@ -183,46 +178,53 @@ function PluginScopeForm({
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 py-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2 py-3 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = (group: PluginSettingsField[]) =>
|
||||
group
|
||||
.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]}
|
||||
/>
|
||||
));
|
||||
|
||||
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 flex-col gap-2 pt-2">
|
||||
{!running && (
|
||||
<div className="border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
|
||||
{t("settings.featureUnavailable", { name: plugin.name })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end px-1">
|
||||
{groups.map((group, index) =>
|
||||
group.key ? (
|
||||
<SectionCard
|
||||
key={group.key}
|
||||
title={t(pluginKey(plugin.id, group.key))}
|
||||
icon={icon}
|
||||
>
|
||||
{rows(group.fields)}
|
||||
</SectionCard>
|
||||
) : (
|
||||
<div key={`group-${index}`}>{rows(group.fields)}</div>
|
||||
),
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
@@ -233,6 +235,6 @@ function PluginScopeForm({
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -139,7 +139,7 @@ function HostPluginSection({
|
||||
>
|
||||
{!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 })}
|
||||
{t("settings.featureUnavailable", { name: plugin.name })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -80,20 +80,49 @@ export function SettingsFieldRow({
|
||||
<span className="text-destructive">{error}</span>
|
||||
) : undefined;
|
||||
|
||||
const control = (
|
||||
<FieldControl
|
||||
pluginId={pluginId}
|
||||
field={field}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
);
|
||||
|
||||
// A switch fits beside its label. Anything wider goes underneath, since
|
||||
// these rows also render in a narrow sidebar.
|
||||
if (field.type === "boolean") {
|
||||
return (
|
||||
<SettingRow
|
||||
label={label(field.labelKey)}
|
||||
description={description}
|
||||
badge={error ? t("common.error") : undefined}
|
||||
>
|
||||
{control}
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-sm font-medium leading-snug">
|
||||
{label(field.labelKey)}
|
||||
</span>
|
||||
{error && (
|
||||
<span className="text-[10px] font-bold text-yellow-500 border border-yellow-500/40 px-1 shrink-0">
|
||||
{t("common.error")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">{control}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,7 +159,7 @@ function FieldControl({
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
className="h-7 w-40 text-sm"
|
||||
className="h-8 w-full max-w-40 text-sm"
|
||||
disabled={readOnly}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
@@ -152,7 +181,7 @@ function FieldControl({
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onValueChange={(next) => setValue(field.key, next)}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-52 text-sm">
|
||||
<SelectTrigger className="h-8 w-full min-w-0 text-sm">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -168,13 +197,12 @@ function FieldControl({
|
||||
case "multiselect": {
|
||||
const selected = Array.isArray(value) ? (value as string[]) : [];
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 items-end">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{(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)}
|
||||
@@ -187,6 +215,7 @@ function FieldControl({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>{label(option.labelKey)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -208,7 +237,7 @@ function FieldControl({
|
||||
case "json":
|
||||
return (
|
||||
<Textarea
|
||||
className="w-64 text-sm font-mono min-h-16"
|
||||
className="w-full text-sm font-mono min-h-16"
|
||||
disabled={readOnly}
|
||||
placeholder={placeholder}
|
||||
value={
|
||||
@@ -225,7 +254,7 @@ function FieldControl({
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
className="h-7 w-52 text-sm"
|
||||
className="h-8 w-full text-sm"
|
||||
disabled={readOnly}
|
||||
placeholder={placeholder}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
@@ -259,7 +288,7 @@ function SecretControl({
|
||||
|
||||
if (redacted) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{stored ? t("settings.secretSet") : t("settings.secretNotSet")}
|
||||
</span>
|
||||
@@ -277,14 +306,16 @@ function SecretControl({
|
||||
}
|
||||
|
||||
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)}
|
||||
/>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<PasswordInput
|
||||
className="h-8 w-full text-sm"
|
||||
disabled={readOnly}
|
||||
placeholder={placeholder}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => setValue(field.key, event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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[]>([]);
|
||||
|
||||
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 @@
|
||||
/**
|
||||
* Gives every feature with manifest-declared host settings its own host editor
|
||||
* tab, named after the feature. A plugin that registered its own host editor
|
||||
* section already edits its settings there, so it gets no generated tab.
|
||||
*
|
||||
* Re-run whenever the plugin list changes so tabs come and go without a reload.
|
||||
*/
|
||||
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import {
|
||||
hostEditorSectionList,
|
||||
registerHostEditorSection,
|
||||
unregisterHostEditorSection,
|
||||
} from "@/sidebar/HostManagerTabs";
|
||||
import type { PluginSummary } from "@/api/plugins-api";
|
||||
import {
|
||||
HostPluginSections,
|
||||
type HostPluginSettings,
|
||||
} from "./HostPluginSections";
|
||||
import { hasVisibleFields } from "./settings-fields-util";
|
||||
|
||||
const TAB_PREFIX = "feature:";
|
||||
|
||||
export function hostFeatureTabId(pluginId: string): string {
|
||||
return `${TAB_PREFIX}${pluginId}`;
|
||||
}
|
||||
|
||||
interface HostFormLike {
|
||||
pluginSettings?: HostPluginSettings;
|
||||
}
|
||||
|
||||
/** Adds, updates and removes generated tabs to match the plugin list. */
|
||||
export function syncHostFeatureTabs(plugins: PluginSummary[]): string[] {
|
||||
const existing = hostEditorSectionList();
|
||||
const ownSections = new Set(
|
||||
existing
|
||||
.filter(
|
||||
(section) => section.pluginId && !section.id.startsWith(TAB_PREFIX),
|
||||
)
|
||||
.map((section) => section.pluginId),
|
||||
);
|
||||
|
||||
const contributors = plugins.filter((plugin) => {
|
||||
if (!plugin.enabled || ownSections.has(plugin.id)) return false;
|
||||
const host = plugin.contributes?.settings?.host;
|
||||
return !!host && (hasVisibleFields(host.fields) || !!host.enableKey);
|
||||
});
|
||||
|
||||
const wanted = new Set(
|
||||
contributors.map((plugin) => hostFeatureTabId(plugin.id)),
|
||||
);
|
||||
for (const section of existing) {
|
||||
if (section.id.startsWith(TAB_PREFIX) && !wanted.has(section.id)) {
|
||||
unregisterHostEditorSection(section.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const plugin of contributors) {
|
||||
const host = plugin.contributes!.settings!.host!;
|
||||
const icon = plugin.icon;
|
||||
registerHostEditorSection({
|
||||
id: hostFeatureTabId(plugin.id),
|
||||
pluginId: plugin.id,
|
||||
group: host.editorGroup ?? "top",
|
||||
order: host.editorOrder ?? 100,
|
||||
labelKey: plugin.name,
|
||||
label: plugin.name,
|
||||
icon: icon
|
||||
? ({ className }: { className?: string }) => (
|
||||
<PluginIcon name={icon} className={className} />
|
||||
)
|
||||
: undefined,
|
||||
component: ({ form, setField }) => (
|
||||
<HostPluginSections
|
||||
plugins={[plugin]}
|
||||
values={(form as HostFormLike)?.pluginSettings ?? {}}
|
||||
setValue={(pluginId, key, value) => {
|
||||
const current = (form as HostFormLike)?.pluginSettings ?? {};
|
||||
setField("pluginSettings", {
|
||||
...current,
|
||||
[pluginId]: { ...(current[pluginId] ?? {}), [key]: value },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return [...wanted];
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* Registers the host editor's "Plugins" tab.
|
||||
*
|
||||
* Goes through the host editor section registry rather than a case in the
|
||||
* editor, 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 {
|
||||
registerHostEditorSection,
|
||||
unregisterHostEditorSection,
|
||||
} from "@/sidebar/HostManagerTabs";
|
||||
import type { PluginSummary } from "@/api/plugins-api";
|
||||
import {
|
||||
HostPluginSections,
|
||||
type HostPluginSettings,
|
||||
} from "./HostPluginSections";
|
||||
import { hasVisibleFields } from "./settings-fields-util";
|
||||
|
||||
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 && (hasVisibleFields(host.fields) || !!host.enableKey);
|
||||
});
|
||||
|
||||
if (contributors.length === 0) {
|
||||
unregisterHostEditorSection(HOST_PLUGINS_TAB_ID);
|
||||
return false;
|
||||
}
|
||||
|
||||
registerHostEditorSection({
|
||||
id: HOST_PLUGINS_TAB_ID,
|
||||
// In the main strip, so hosts without SSH reach it too.
|
||||
group: "top",
|
||||
order: 100,
|
||||
labelKey: "settings.pluginsGroupLabel",
|
||||
icon: Puzzle,
|
||||
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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ComponentType, Ref } from "react";
|
||||
import type { HostDraft } from "@termix/plugin-sdk/frontend";
|
||||
import type { Host, Tab } from "@/types/ui-types";
|
||||
import { createRegistry } from "@/lib/registry";
|
||||
import { viewOwner } from "@/plugin-host/view-ownership";
|
||||
@@ -27,6 +28,8 @@ export interface TabShellCallbacks {
|
||||
openRailView: (id: string) => void;
|
||||
/** Closes a rail view wherever it is shown, e.g. when its feature goes away. */
|
||||
closeRailView: (id: string) => void;
|
||||
/** Opens the host editor for a new host with these fields filled in. */
|
||||
openHostEditor?: (draft?: HostDraft) => void;
|
||||
/** Saves a quick-connect tab's host. Core terminal only. */
|
||||
saveQuickConnect?: (tab: Tab, host: Host) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Puzzle, ShieldCheck } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { SettingRow } from "@/components/section-card";
|
||||
import { Button } from "@/components/button";
|
||||
import {
|
||||
getPlugins,
|
||||
setPluginEnabled,
|
||||
type PluginSummary,
|
||||
} from "@/api/plugins-api";
|
||||
import { syncPlugins } from "@/plugin-host/loader";
|
||||
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
|
||||
import { PluginPermissionsDialog } from "./PluginPermissionsDialog";
|
||||
|
||||
export function AdminPluginsSection({
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [permissionsTargetId, setPermissionsTargetId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const permissionsTarget =
|
||||
plugins.find((plugin) => plugin.id === permissionsTargetId) ?? null;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setPlugins(await getPlugins());
|
||||
} catch {
|
||||
toast.error(t("admin.pluginsLoadFailed"));
|
||||
} finally {
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !loaded) void load();
|
||||
}, [open, loaded, load]);
|
||||
|
||||
const toggle = async (plugin: PluginSummary) => {
|
||||
setBusy(plugin.id);
|
||||
try {
|
||||
await setPluginEnabled(plugin.id, !plugin.enabled);
|
||||
await load();
|
||||
// Re-read state so the shell picks the change up without a reload.
|
||||
await syncPlugins();
|
||||
toast.success(
|
||||
plugin.enabled
|
||||
? t("admin.pluginDisabled", { name: plugin.name })
|
||||
: t("admin.pluginEnabled", { name: plugin.name }),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("admin.pluginToggleFailed"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AccordionSection
|
||||
label={t("admin.plugins")}
|
||||
icon={<Puzzle className="size-3.5" />}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
{plugins.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-3">
|
||||
{loaded ? t("admin.pluginsNone") : t("common.loading")}
|
||||
</p>
|
||||
) : (
|
||||
plugins.map((plugin) => (
|
||||
<SettingRow
|
||||
key={plugin.id}
|
||||
label={plugin.name}
|
||||
badge={
|
||||
plugin.tier === "bundled" ? t("admin.pluginBuiltIn") : undefined
|
||||
}
|
||||
description={
|
||||
plugin.lastError
|
||||
? plugin.lastError
|
||||
: `v${plugin.version} · ${plugin.state}`
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{(plugin.capabilities?.length ?? 0) > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-none h-7 px-2 text-[10px] font-bold uppercase tracking-widest"
|
||||
onClick={() => setPermissionsTargetId(plugin.id)}
|
||||
>
|
||||
<ShieldCheck className="size-3" />
|
||||
{t("admin.pluginPermissions")}
|
||||
</Button>
|
||||
)}
|
||||
<AdminToggle
|
||||
on={plugin.enabled}
|
||||
onToggle={() => void toggle(plugin)}
|
||||
disabled={busy === plugin.id}
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
))
|
||||
)}
|
||||
</AccordionSection>
|
||||
|
||||
<PluginPermissionsDialog
|
||||
plugin={permissionsTarget}
|
||||
open={permissionsTargetId !== null}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setPermissionsTargetId(null);
|
||||
}}
|
||||
onChanged={() => void load()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -81,9 +81,7 @@ export function AdminSecondFactorsSection({
|
||||
</span>
|
||||
{!factor.available && (
|
||||
<span className="text-[10px] text-destructive">
|
||||
{t("admin.secondFactorUnavailable", {
|
||||
plugin: factor.pluginId,
|
||||
})}
|
||||
{t("admin.secondFactorUnavailable")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,12 @@ import {
|
||||
type UserRole,
|
||||
} from "@/main-axios";
|
||||
import { type AdminSection, type Host } from "@/types/ui-types";
|
||||
import { AdminPluginsSection } from "./AdminPluginsSection";
|
||||
import {
|
||||
FeatureSettingsSection,
|
||||
featureSectionId,
|
||||
useFeatureSettings,
|
||||
type FeatureSectionId,
|
||||
} from "@/settings/FeatureSettingsSections";
|
||||
import {
|
||||
AdminRolesSection,
|
||||
AdminSessionsSection,
|
||||
@@ -109,9 +114,10 @@ export function AdminSettingsPanel({
|
||||
onOpenHostTab?: (host: Host) => void;
|
||||
} = {}) {
|
||||
const { t } = useTranslation();
|
||||
const [openSections, setOpenSections] = useState<Set<AdminSection>>(
|
||||
() => new Set(["general"]),
|
||||
);
|
||||
const [openSections, setOpenSections] = useState<
|
||||
Set<AdminSection | FeatureSectionId>
|
||||
>(() => new Set(["general"]));
|
||||
const featureSettings = useFeatureSettings("admin");
|
||||
const [manageUser, setManageUser] = useState<AdminUser | null>(null);
|
||||
const [allowRegistration, setAllowRegistration] = useState(true);
|
||||
const [allowPasswordLogin, setAllowPasswordLogin] = useState(true);
|
||||
@@ -358,7 +364,7 @@ export function AdminSettingsPanel({
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function toggle(id: AdminSection) {
|
||||
function toggle(id: AdminSection | FeatureSectionId) {
|
||||
setOpenSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
@@ -970,10 +976,15 @@ export function AdminSettingsPanel({
|
||||
users={users}
|
||||
/>
|
||||
|
||||
<AdminPluginsSection
|
||||
open={openSections.has("plugins")}
|
||||
onToggle={() => toggle("plugins")}
|
||||
/>
|
||||
{featureSettings.map((plugin) => (
|
||||
<FeatureSettingsSection
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
scope="admin"
|
||||
open={openSections.has(featureSectionId(plugin.id))}
|
||||
onToggle={() => toggle(featureSectionId(plugin.id))}
|
||||
/>
|
||||
))}
|
||||
|
||||
<AdminCreateUserDialog
|
||||
open={createUserOpen}
|
||||
|
||||
@@ -60,6 +60,7 @@ import type { SSHHost } from "@/types";
|
||||
import { useTabsSafe } from "@/shell/TabContext";
|
||||
import { updatePluginHostSettings } from "@/api/plugins-api";
|
||||
import {
|
||||
applyHostDraft,
|
||||
buildHostEditorPayload,
|
||||
createHostEditorForm,
|
||||
mapSnippetResponse,
|
||||
@@ -89,6 +90,7 @@ import { useSshAuthEditors } from "@/plugin-host/auth-registry";
|
||||
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
||||
import { SshAuthProviderFields } from "./SshAuthProviderFields";
|
||||
import type { PluginSettingsField } from "@termix/plugin-sdk/manifest";
|
||||
import type { HostDraft } from "@termix/plugin-sdk/frontend";
|
||||
import { PluginComponent } from "@/plugin-host/component-registry";
|
||||
import {
|
||||
toCredentialOption,
|
||||
@@ -99,6 +101,7 @@ const CUSTOM_FONT_OPTION = "__custom__";
|
||||
|
||||
export function HostEditor({
|
||||
host,
|
||||
draft,
|
||||
activeTab,
|
||||
onBack,
|
||||
onSave,
|
||||
@@ -113,6 +116,8 @@ export function HostEditor({
|
||||
onEditCredential,
|
||||
}: {
|
||||
host: Host | null;
|
||||
/** Fields a plugin filled in for a new host. */
|
||||
draft?: HostDraft;
|
||||
activeTab: string;
|
||||
/** Collapses the General tab to the fields needed to reach a host. */
|
||||
simpleMode?: boolean;
|
||||
@@ -136,7 +141,10 @@ export function HostEditor({
|
||||
const { setPreviewTerminalTheme } = useTabsSafe();
|
||||
const connectionDefaults = useConnectionDefaults();
|
||||
const [form, setForm] = useState(() =>
|
||||
createHostEditorForm(host, undefined, connectionDefaults),
|
||||
applyHostDraft(
|
||||
createHostEditorForm(host, undefined, connectionDefaults),
|
||||
host ? undefined : draft,
|
||||
),
|
||||
);
|
||||
const [isCustomFont, setIsCustomFont] = useState(
|
||||
() => !TERMINAL_FONTS.some((f) => f.value === form.fontFamily),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TERMINAL_THEMES } from "@/lib/terminal-themes";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { SSHHostData } from "@/types";
|
||||
import type { HostDraft } from "@termix/plugin-sdk/frontend";
|
||||
import type { HostDefaults } from "@/api/settings-api";
|
||||
import type { TerminalDefaults } from "@/lib/connection-defaults";
|
||||
import {
|
||||
@@ -89,6 +90,24 @@ export function mapSnippetResponse(
|
||||
}));
|
||||
}
|
||||
|
||||
/** Overlays a plugin's draft on a new host's form. */
|
||||
export function applyHostDraft(
|
||||
form: HostEditorForm,
|
||||
draft: HostDraft | undefined,
|
||||
): HostEditorForm {
|
||||
if (!draft) return form;
|
||||
return {
|
||||
...form,
|
||||
...(draft.name !== undefined ? { name: draft.name } : {}),
|
||||
...(draft.ip !== undefined ? { ip: draft.ip } : {}),
|
||||
...(draft.port !== undefined ? { sshPort: draft.port } : {}),
|
||||
...(draft.username !== undefined ? { username: draft.username } : {}),
|
||||
...(draft.authType !== undefined
|
||||
? { authType: draft.authType as HostAuthType }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createHostEditorForm(
|
||||
host: Host | null,
|
||||
defaults?: HostDefaults,
|
||||
|
||||
@@ -11,6 +11,7 @@ import React, {
|
||||
type MutableRefObject,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { HostDraft } from "@termix/plugin-sdk/frontend";
|
||||
import { CredentialShareModal } from "./CredentialShareModal";
|
||||
|
||||
import { Button } from "@/components/button";
|
||||
@@ -82,6 +83,12 @@ export function HostManager({
|
||||
useHostEditorSections();
|
||||
const { t } = useTranslation();
|
||||
const [editingHost, setEditingHost] = useState<Host | "new" | null>(null);
|
||||
// Fields a plugin filled in for a new host, keyed so a second draft remounts
|
||||
// the editor.
|
||||
const [hostDraft, setHostDraft] = useState<{
|
||||
key: number;
|
||||
draft: HostDraft;
|
||||
} | null>(null);
|
||||
const [shareCredential, setShareCredential] = useState<Credential | null>(
|
||||
null,
|
||||
);
|
||||
@@ -189,6 +196,7 @@ export function HostManager({
|
||||
pendingAction.current = null;
|
||||
if (action === "add-host") {
|
||||
setEditingHost("new");
|
||||
setHostDraft(null);
|
||||
setEditingCredential(null);
|
||||
setEditingProtocols(hostProtocolFlags(null));
|
||||
setActiveHostTab("general");
|
||||
@@ -202,8 +210,10 @@ export function HostManager({
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const handleAddHost = () => {
|
||||
const handleAddHost = (e: Event) => {
|
||||
const draft = (e as CustomEvent<HostDraft | undefined>).detail;
|
||||
setEditingHost("new");
|
||||
setHostDraft(draft ? { key: Date.now(), draft } : null);
|
||||
setEditingCredential(null);
|
||||
setEditingProtocols(hostProtocolFlags(null));
|
||||
setActiveHostTab("general");
|
||||
@@ -518,9 +528,12 @@ export function HostManager({
|
||||
{isHost ? (
|
||||
<HostEditor
|
||||
key={
|
||||
editingHost === "new" ? "new-host" : (editingHost as Host).id
|
||||
editingHost === "new"
|
||||
? `new-host-${hostDraft?.key ?? ""}`
|
||||
: (editingHost as Host).id
|
||||
}
|
||||
host={editingHost === "new" ? null : (editingHost as Host)}
|
||||
draft={editingHost === "new" ? hostDraft?.draft : undefined}
|
||||
activeTab={effectiveHostTab}
|
||||
simpleMode={collapseAdvanced}
|
||||
onBack={requestCloseHostEditor}
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface HostEditorSectionDef {
|
||||
pluginId?: string;
|
||||
group: "top" | "ssh";
|
||||
labelKey: string;
|
||||
/** Already-translated label, used instead of labelKey when set. */
|
||||
label?: string;
|
||||
icon?: ComponentType<{ className?: string }>;
|
||||
/** Position among the group's tabs, core ones included. */
|
||||
order?: number;
|
||||
@@ -96,7 +98,7 @@ function mergeByOrder(
|
||||
const Icon = section.icon;
|
||||
return {
|
||||
id: section.id,
|
||||
label: t(section.labelKey),
|
||||
label: section.label ?? t(section.labelKey),
|
||||
icon: Icon ? <Icon className="size-3" /> : null,
|
||||
order: section.order ?? 1000,
|
||||
};
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import {
|
||||
grantPluginCapability,
|
||||
revokePluginCapability,
|
||||
type PluginSummary,
|
||||
} from "@/api/plugins-api";
|
||||
import { AdminToggle } from "./AdminSettingsShared";
|
||||
import { CAPABILITY_CATALOG } from "@termix/plugin-sdk/capabilities";
|
||||
|
||||
/**
|
||||
* The catalog is the list. It is ordered worst first, so the riskiest thing a
|
||||
* plugin asked for is the first row an admin reads rather than something they
|
||||
* have to scroll for.
|
||||
*/
|
||||
const LABELED_PERMISSIONS = CAPABILITY_CATALOG.map((capability) => ({
|
||||
capability: capability.id,
|
||||
labelKey: capability.titleKey,
|
||||
descriptionKey: capability.consequenceKey,
|
||||
}));
|
||||
|
||||
export function PluginPermissionsDialog({
|
||||
plugin,
|
||||
open,
|
||||
onOpenChange,
|
||||
onChanged,
|
||||
}: {
|
||||
plugin: PluginSummary | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
if (!plugin) return null;
|
||||
|
||||
const declared = plugin.capabilities ?? [];
|
||||
const rows = LABELED_PERMISSIONS.filter((row) =>
|
||||
declared.includes(row.capability),
|
||||
);
|
||||
const granted = new Set(plugin.grantedCapabilities ?? []);
|
||||
const publicRoutes = [
|
||||
...(plugin.publicRoutes?.http ?? []).map(
|
||||
(path) => `/plugin-api/${plugin.id}${path}`,
|
||||
),
|
||||
...(plugin.publicRoutes?.ws ?? []).map(
|
||||
(path) => `/plugin-ws/${plugin.id}${path}`,
|
||||
),
|
||||
];
|
||||
|
||||
const toggle = async (capability: string, isGranted: boolean) => {
|
||||
setBusy(capability);
|
||||
try {
|
||||
if (isGranted) {
|
||||
await revokePluginCapability(plugin.id, capability);
|
||||
} else {
|
||||
await grantPluginCapability(plugin.id, capability);
|
||||
}
|
||||
onChanged();
|
||||
} catch {
|
||||
toast.error(
|
||||
isGranted
|
||||
? t("admin.pluginRevokeFailed")
|
||||
: t("admin.pluginGrantFailed"),
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg rounded-none border-border bg-card">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xs font-bold uppercase tracking-widest flex items-center gap-2">
|
||||
<ShieldCheck className="size-4 text-accent-brand" />
|
||||
{t("admin.pluginPermissions")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[10px] font-bold tracking-tight text-muted-foreground">
|
||||
{plugin.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-3">
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-3">
|
||||
{t("admin.pluginPermissionsNone")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="border border-border overflow-hidden">
|
||||
{rows.map((row, i) => {
|
||||
const isGranted = granted.has(row.capability);
|
||||
return (
|
||||
<div
|
||||
key={row.capability}
|
||||
className={`flex items-center justify-between gap-3 px-3 py-3 ${
|
||||
i < rows.length - 1 ? "border-b border-border" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold">
|
||||
{t(row.labelKey)}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground leading-snug">
|
||||
{t(row.descriptionKey)}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mt-0.5">
|
||||
{isGranted
|
||||
? t("admin.pluginPermissionGranted")
|
||||
: t("admin.pluginPermissionNotGranted")}
|
||||
</span>
|
||||
</div>
|
||||
<AdminToggle
|
||||
on={isGranted}
|
||||
onToggle={() => void toggle(row.capability, isGranted)}
|
||||
disabled={busy === row.capability}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{publicRoutes.length > 0 && (
|
||||
<div className="pb-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1">
|
||||
{t("admin.pluginPublicRoutes")}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground leading-snug mb-2">
|
||||
{t("admin.pluginPublicRoutesDescription")}
|
||||
</p>
|
||||
<div className="border border-border">
|
||||
{publicRoutes.map((route) => (
|
||||
<div
|
||||
key={route}
|
||||
className="px-3 py-1.5 text-[11px] font-mono break-all border-b border-border last:border-b-0"
|
||||
>
|
||||
{route}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -76,9 +76,21 @@ import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { clearLocalAdaptivePreferences } from "@/lib/local-adaptive-preferences";
|
||||
import { ConnectionDefaultsSettings } from "./ConnectionDefaultsSettings";
|
||||
import {
|
||||
FeatureSettingsSection,
|
||||
featureSectionId,
|
||||
useFeatureSettings,
|
||||
type FeatureSectionId,
|
||||
} from "@/settings/FeatureSettingsSections";
|
||||
|
||||
type UserProfileSection =
|
||||
"account" | "interface" | "appearance" | "security" | "api-keys" | "data";
|
||||
| "account"
|
||||
| "interface"
|
||||
| "appearance"
|
||||
| "security"
|
||||
| "api-keys"
|
||||
| "data"
|
||||
| FeatureSectionId;
|
||||
|
||||
const THEMES: { id: ThemeId; preview: string }[] = [
|
||||
{ id: "system", preview: "auto" },
|
||||
@@ -557,6 +569,7 @@ export function UserProfilePanel({
|
||||
const [openSections, setOpenSections] = useState<Set<UserProfileSection>>(
|
||||
() => new Set(["account"]),
|
||||
);
|
||||
const featureSettings = useFeatureSettings("user");
|
||||
|
||||
// User info
|
||||
const [userId, setUserId] = useState("");
|
||||
@@ -2201,6 +2214,16 @@ export function UserProfilePanel({
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
{featureSettings.map((plugin) => (
|
||||
<FeatureSettingsSection
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
scope="user"
|
||||
open={openSections.has(featureSectionId(plugin.id))}
|
||||
onToggle={() => toggle(featureSectionId(plugin.id))}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Delete account dialog */}
|
||||
<Dialog
|
||||
open={showDeleteConfirm}
|
||||
|
||||
@@ -15,6 +15,16 @@ type QuickConnectInput = Pick<
|
||||
|
||||
const QUICK_CONNECT_ID_PREFIX = "quick-connect-";
|
||||
|
||||
// The auth types quickConnectHostToPayload carries over. A plugin auth type
|
||||
// keeps its fields elsewhere, so saving it here would lose them.
|
||||
const SAVABLE_AUTH_TYPES = new Set([
|
||||
"password",
|
||||
"key",
|
||||
"credential",
|
||||
"none",
|
||||
"agent",
|
||||
]);
|
||||
|
||||
export function isQuickConnectHost(host: Pick<Host, "id">): boolean {
|
||||
return host.id.startsWith(QUICK_CONNECT_ID_PREFIX);
|
||||
}
|
||||
@@ -33,6 +43,7 @@ export function createQuickConnectHost(input: QuickConnectInput): Host {
|
||||
...login,
|
||||
port: input.port,
|
||||
enableSsh: false,
|
||||
quickConnectSavable: false,
|
||||
domain: input.domain,
|
||||
pluginSettings: {
|
||||
[protocol.pluginId]: {
|
||||
@@ -62,6 +73,8 @@ export function createQuickConnectHost(input: QuickConnectInput): Host {
|
||||
quickActions: [],
|
||||
enableSsh: true,
|
||||
sshPort: input.port,
|
||||
quickConnectSavable:
|
||||
!input.authFields && SAVABLE_AUTH_TYPES.has(input.authType),
|
||||
...input.authFields,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import type { PluginSummary } from "@/api/plugins-api";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getPlugins: vi.fn(),
|
||||
getPluginAdminSettings: vi.fn(),
|
||||
getPluginUserSettings: vi.fn(),
|
||||
updatePluginAdminSettings: vi.fn(),
|
||||
updatePluginUserSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/plugins-api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/api/plugins-api")>()),
|
||||
...api,
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => {
|
||||
const t = (key: string, options?: { name?: string }) =>
|
||||
options?.name ? `${key}:${options.name}` : key;
|
||||
return { useTranslation: () => ({ t }) };
|
||||
});
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import {
|
||||
FeatureSettingsSection,
|
||||
useFeatureSettings,
|
||||
} from "@/settings/FeatureSettingsSections";
|
||||
|
||||
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
||||
return {
|
||||
id: "ai",
|
||||
name: "AI Assistant",
|
||||
version: "1.0.0",
|
||||
enabled: true,
|
||||
state: "active",
|
||||
contributes: {
|
||||
settings: {
|
||||
admin: [{ key: "on", type: "boolean", labelKey: "admin.on" }],
|
||||
user: [{ key: "enabled", type: "boolean", labelKey: "user.enabled" }],
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
} as PluginSummary;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("useFeatureSettings", () => {
|
||||
it("keeps enabled features with visible fields in the scope, sorted by name", async () => {
|
||||
api.getPlugins.mockResolvedValue([
|
||||
plugin({ id: "zeta", name: "Zeta" }),
|
||||
plugin({ id: "off", name: "Off", enabled: false }),
|
||||
plugin({
|
||||
id: "hidden",
|
||||
name: "Hidden",
|
||||
contributes: {
|
||||
settings: {
|
||||
user: [{ key: "x", type: "boolean", labelKey: "x", hidden: true }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
plugin({ id: "alpha", name: "Alpha" }),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useFeatureSettings("user"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.map((p) => p.id)).toEqual(["alpha", "zeta"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves out features with nothing in the scope", async () => {
|
||||
api.getPlugins.mockResolvedValue([
|
||||
plugin({
|
||||
id: "admin-only",
|
||||
contributes: {
|
||||
settings: {
|
||||
admin: [{ key: "a", type: "boolean", labelKey: "a" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useFeatureSettings("user"));
|
||||
|
||||
await waitFor(() => expect(api.getPlugins).toHaveBeenCalled());
|
||||
expect(result.current).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeatureSettingsSection", () => {
|
||||
it("is titled with the feature name and loads nothing while closed", () => {
|
||||
render(
|
||||
<FeatureSettingsSection
|
||||
plugin={plugin()}
|
||||
scope="user"
|
||||
open={false}
|
||||
onToggle={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("AI Assistant")).toBeTruthy();
|
||||
expect(api.getPluginUserSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads and saves the scope's values once open", async () => {
|
||||
api.getPluginUserSettings.mockResolvedValue({ enabled: false });
|
||||
api.updatePluginUserSettings.mockResolvedValue({ enabled: true });
|
||||
|
||||
render(
|
||||
<FeatureSettingsSection
|
||||
plugin={plugin()}
|
||||
scope="user"
|
||||
open
|
||||
onToggle={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText("ai:user.enabled");
|
||||
expect(api.getPluginUserSettings).toHaveBeenCalledWith("ai");
|
||||
expect(api.getPluginAdminSettings).not.toHaveBeenCalled();
|
||||
|
||||
const save = screen.getByText("common.save").closest("button")!;
|
||||
expect(save.disabled).toBe(true);
|
||||
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b !== save && b.textContent === "")!,
|
||||
);
|
||||
fireEvent.click(save);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.updatePluginUserSettings).toHaveBeenCalledWith("ai", {
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the admin endpoints for the admin scope", async () => {
|
||||
api.getPluginAdminSettings.mockResolvedValue({ on: true });
|
||||
|
||||
render(
|
||||
<FeatureSettingsSection
|
||||
plugin={plugin()}
|
||||
scope="admin"
|
||||
open
|
||||
onToggle={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText("ai:admin.on");
|
||||
expect(api.getPluginAdminSettings).toHaveBeenCalledWith("ai");
|
||||
});
|
||||
|
||||
it("says when the feature is unavailable", async () => {
|
||||
api.getPluginUserSettings.mockResolvedValue({});
|
||||
|
||||
render(
|
||||
<FeatureSettingsSection
|
||||
plugin={plugin({ state: "failed" })}
|
||||
scope="user"
|
||||
open
|
||||
onToggle={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText("settings.featureUnavailable:AI Assistant"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
/**
|
||||
* 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>,
|
||||
}));
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
+67
-33
@@ -1,21 +1,18 @@
|
||||
/**
|
||||
* 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.
|
||||
* Generated host editor tabs, one per feature with manifest host settings.
|
||||
*/
|
||||
|
||||
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";
|
||||
hostFeatureTabId,
|
||||
syncHostFeatureTabs,
|
||||
} from "@/settings/host-feature-tabs";
|
||||
import {
|
||||
getHostEditorSection,
|
||||
unregisterHostEditorSection,
|
||||
registerHostEditorSection,
|
||||
resetHostEditorSections,
|
||||
} from "@/sidebar/HostManagerTabs";
|
||||
import { HostPluginSections } from "@/settings/HostPluginSections";
|
||||
|
||||
@@ -47,18 +44,62 @@ function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
unregisterHostEditorSection(HOST_PLUGINS_TAB_ID);
|
||||
resetHostEditorSections();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
unregisterHostEditorSection(HOST_PLUGINS_TAB_ID);
|
||||
resetHostEditorSections();
|
||||
});
|
||||
|
||||
describe("syncHostPluginsTab", () => {
|
||||
it("registers the tab when a plugin contributes host settings", () => {
|
||||
expect(syncHostPluginsTab([plugin()])).toBe(true);
|
||||
expect(getHostEditorSection(HOST_PLUGINS_TAB_ID)).toBeDefined();
|
||||
describe("syncHostFeatureTabs", () => {
|
||||
it("registers one tab per feature, labelled with its name", () => {
|
||||
const other = plugin({ id: "warpgate", name: "Warpgate" });
|
||||
expect(syncHostFeatureTabs([plugin(), other])).toEqual([
|
||||
"feature:docker",
|
||||
"feature:warpgate",
|
||||
]);
|
||||
expect(getHostEditorSection(hostFeatureTabId("docker"))?.label).toBe(
|
||||
"Docker",
|
||||
);
|
||||
expect(getHostEditorSection(hostFeatureTabId("warpgate"))).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses the manifest's editor group and order", () => {
|
||||
const placed = plugin({
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
editorGroup: "ssh",
|
||||
editorOrder: 30,
|
||||
enableKey: "enableDocker",
|
||||
enableLabelKey: "k",
|
||||
fields: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
syncHostFeatureTabs([placed]);
|
||||
const section = getHostEditorSection(hostFeatureTabId("docker"));
|
||||
expect(section?.group).toBe("ssh");
|
||||
expect(section?.order).toBe(30);
|
||||
});
|
||||
|
||||
it("defaults to the main strip", () => {
|
||||
syncHostFeatureTabs([plugin()]);
|
||||
expect(getHostEditorSection(hostFeatureTabId("docker"))?.group).toBe("top");
|
||||
});
|
||||
|
||||
it("skips a plugin that registered its own host editor section", () => {
|
||||
registerHostEditorSection({
|
||||
id: "docker-own",
|
||||
pluginId: "docker",
|
||||
group: "top",
|
||||
labelKey: "k",
|
||||
component: () => null,
|
||||
});
|
||||
expect(syncHostFeatureTabs([plugin()])).toEqual([]);
|
||||
expect(getHostEditorSection(hostFeatureTabId("docker"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("registers nothing for a plugin whose host fields are all hidden", () => {
|
||||
@@ -73,12 +114,7 @@ describe("syncHostPluginsTab", () => {
|
||||
},
|
||||
},
|
||||
} as Partial<PluginSummary>);
|
||||
expect(syncHostPluginsTab([own])).toBe(false);
|
||||
});
|
||||
|
||||
it("registers nothing when no plugin contributes", () => {
|
||||
expect(syncHostPluginsTab([])).toBe(false);
|
||||
expect(getHostEditorSection(HOST_PLUGINS_TAB_ID)).toBeUndefined();
|
||||
expect(syncHostFeatureTabs([own])).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores a plugin with no host settings", () => {
|
||||
@@ -86,21 +122,20 @@ describe("syncHostPluginsTab", () => {
|
||||
id: "ai",
|
||||
contributes: { settings: { admin: [] } },
|
||||
});
|
||||
|
||||
expect(syncHostPluginsTab([other])).toBe(false);
|
||||
expect(syncHostFeatureTabs([other])).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores a disabled plugin", () => {
|
||||
expect(syncHostPluginsTab([plugin({ enabled: false })])).toBe(false);
|
||||
expect(syncHostFeatureTabs([plugin({ enabled: false })])).toEqual([]);
|
||||
});
|
||||
|
||||
it("removes the tab once the last contributor goes", () => {
|
||||
syncHostPluginsTab([plugin()]);
|
||||
expect(getHostEditorSection(HOST_PLUGINS_TAB_ID)).toBeDefined();
|
||||
it("removes a tab once its feature goes", () => {
|
||||
syncHostFeatureTabs([plugin()]);
|
||||
expect(getHostEditorSection(hostFeatureTabId("docker"))).toBeDefined();
|
||||
|
||||
syncHostPluginsTab([plugin({ enabled: false })]);
|
||||
syncHostFeatureTabs([plugin({ enabled: false })]);
|
||||
|
||||
expect(getHostEditorSection(HOST_PLUGINS_TAB_ID)).toBeUndefined();
|
||||
expect(getHostEditorSection(hostFeatureTabId("docker"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("registers for a plugin declaring only an enable switch", () => {
|
||||
@@ -111,8 +146,7 @@ describe("syncHostPluginsTab", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(syncHostPluginsTab([bare])).toBe(true);
|
||||
expect(syncHostFeatureTabs([bare])).toEqual(["feature:docker"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,7 +218,7 @@ describe("HostPluginSections", () => {
|
||||
expect(setValue).toHaveBeenCalledWith("docker", "enableDocker", true);
|
||||
});
|
||||
|
||||
it("warns when the plugin is installed but not running", () => {
|
||||
it("warns when the feature is unavailable", () => {
|
||||
render(
|
||||
<HostPluginSections
|
||||
plugins={[plugin({ state: "failed" })]}
|
||||
@@ -193,7 +227,7 @@ describe("HostPluginSections", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("settings.hostPluginNotRunning")).toBeTruthy();
|
||||
expect(screen.getByText("settings.featureUnavailable")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders nothing at all with no contributing plugins", () => {
|
||||
@@ -44,7 +44,7 @@ describe("AdminSecondFactorsSection", () => {
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
await screen.findByText("admin.secondFactorUnavailable:yubi"),
|
||||
await screen.findByText("admin.secondFactorUnavailable"),
|
||||
).toBeTruthy();
|
||||
// A plugin's label resolves in its own namespace.
|
||||
expect(screen.getByText("totp:factor")).toBeTruthy();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { registerHostProtocol } from "../../sidebar/host-protocols";
|
||||
import {
|
||||
applyHostDraft,
|
||||
createHostEditorForm,
|
||||
buildHostEditorPayload,
|
||||
omitOwnerSshAuthFromSharedEdit,
|
||||
@@ -559,3 +560,27 @@ describe("plugin protocols", () => {
|
||||
expect(payload.enableSsh).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyHostDraft", () => {
|
||||
it("fills a new host's form from a plugin draft", () => {
|
||||
const form = applyHostDraft(createHostEditorForm(null), {
|
||||
name: "box",
|
||||
ip: "100.64.0.1",
|
||||
port: 2222,
|
||||
username: "luke",
|
||||
authType: "tailscale",
|
||||
});
|
||||
expect(form).toMatchObject({
|
||||
name: "box",
|
||||
ip: "100.64.0.1",
|
||||
sshPort: 2222,
|
||||
username: "luke",
|
||||
authType: "tailscale",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the form alone without a draft", () => {
|
||||
const form = createHostEditorForm(null);
|
||||
expect(applyHostDraft(form, undefined)).toBe(form);
|
||||
});
|
||||
});
|
||||
@@ -112,4 +112,23 @@ describe("createQuickConnectHost for remote desktop protocols", () => {
|
||||
pluginSettings: { demo: { profileId: 3 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("marks only hosts core can save", () => {
|
||||
const base = { ip: "10.0.0.2", port: 22, username: "root" };
|
||||
expect(
|
||||
createQuickConnectHost({ ...base, authType: "password" })
|
||||
.quickConnectSavable,
|
||||
).toBe(true);
|
||||
expect(
|
||||
createQuickConnectHost({ ...base, authType: "tailscale" })
|
||||
.quickConnectSavable,
|
||||
).toBe(false);
|
||||
expect(
|
||||
createQuickConnectHost({
|
||||
...base,
|
||||
authType: "password",
|
||||
authFields: { extra: true },
|
||||
}).quickConnectSavable,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user