feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning

This commit is contained in:
LukeGus committed 2026-09-25 13:06:33 -05:00
1 parent c21bca8cf0
commit fbe736bc20
70 files changed
+1117 -1139

No files matched your search

+29 -13
View File
@@ -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);
+2 -27
View File
@@ -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
View File
@@ -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",
+2 -2
View File
@@ -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>
+4 -3
View File
@@ -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);
}
/**
+1
View File
@@ -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(),
};
+1
View File
@@ -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 });
},
@@ -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>
);
}
+1 -1
View File
@@ -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>
)}
+59 -28
View File
@@ -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"
-273
View File
@@ -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>
);
}
+90
View File
@@ -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];
}
-73
View File
@@ -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;
}
+3
View File
@@ -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>;
}
-125
View File
@@ -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()}
/>
</>
);
}
+1 -3
View File
@@ -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>
+20 -9
View File
@@ -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}
+9 -1
View File
@@ -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),
+19
View File
@@ -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,
+15 -2
View File
@@ -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}
+3 -1
View File
@@ -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,
};
-158
View File
@@ -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>
);
}
+24 -1
View File
@@ -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}
+13
View File
@@ -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");
});
});
@@ -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);
});
});