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

-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,
};
}