feat: harden plugin api for separate repos, signed updates and generic external login

This commit is contained in:
LukeGus committed 2026-09-29 18:11:59 -05:00
1 parent fb9c168b6b
commit 80944301dd
310 files changed
+6577 -4927

No files matched your search

+14 -75
View File
@@ -230,7 +230,6 @@ import {
} from "@/shell/tab-registry";
import { runHostAction } from "@/sidebar/host-contributions";
import { getPanel, usePanels } from "@/shell/panel-registry";
import { invokeAction } from "@/shell/action-registry";
import { usePluginStore } from "@/plugin-host/plugin-store";
import {
notifyShellReady,
@@ -1142,19 +1141,6 @@ export function AppShell({
);
}, [allHosts]);
// Let HostManager trigger tab opens via custom event
useEffect(() => {
const handle = (e: Event) => {
const { hostId, type } = (
e as CustomEvent<{ hostId: string; type?: TabType }>
).detail;
const host = allHosts.find((h) => h.id === hostId);
if (host) connectHost(host, type);
};
window.addEventListener("termix:open-tab", handle);
return () => window.removeEventListener("termix:open-tab", handle);
}, [allHosts]);
function buildWorkspacePayload(): WorkspacePayload {
return buildLayoutPayload({
tabs,
@@ -1205,13 +1191,16 @@ export function AppShell({
}
if (target.kind === "host") {
const newTabId = openTab(target.host, snapshot.type, {
instanceId: createId(),
restoredSessionId: null,
savedLabel: snapshot.customLabel ?? snapshot.label,
initialFilePath: snapshot.initialFilePath,
initialPath: snapshot.initialPath,
});
const newTabId = openTab(
target.host,
snapshot.type,
{
instanceId: createId(),
restoredSessionId: null,
savedLabel: snapshot.customLabel ?? snapshot.label,
},
{ data: snapshotData(snapshot) },
);
slotIdToNewTabId.set(snapshot.slotId, newTabId);
}
}
@@ -1474,10 +1463,6 @@ export function AppShell({
instanceId: string;
restoredSessionId: string | null;
savedLabel?: string;
initialFilePath?: string;
initialPath?: string;
joinSharedSessionId?: string | null;
joinShareId?: string | null;
},
options?: {
data?: Record<string, unknown>;
@@ -1506,10 +1491,6 @@ export function AppShell({
let finalLabel = host.name;
const savedLabel = restore?.savedLabel;
const initialFilePath = restore?.initialFilePath;
const initialPath = restore?.initialPath;
const joinSharedSessionId = restore?.joinSharedSessionId ?? null;
const joinShareId = restore?.joinShareId ?? null;
// A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label
const isCustomLabel =
savedLabel != null &&
@@ -1531,10 +1512,7 @@ export function AppShell({
openedAt,
terminalRef: ref,
restoredSessionId: restore?.restoredSessionId ?? null,
joinSharedSessionId,
joinShareId,
initialFilePath,
initialPath,
data: options?.data,
},
];
}
@@ -1568,10 +1546,6 @@ export function AppShell({
openedAt,
terminalRef: ref,
restoredSessionId: restore?.restoredSessionId ?? null,
joinSharedSessionId,
joinShareId,
initialFilePath,
initialPath,
data: options?.data,
},
];
@@ -1690,7 +1664,6 @@ export function AppShell({
const id = type;
const singletonLabels: Partial<Record<TabType, string>> = {
"host-manager": t("nav.hostManager"),
sftp: t("nav.sftp"),
};
// A plugin tab names itself; promoted rail panels reuse the rail's own
// label so the two stay in sync.
@@ -1701,7 +1674,7 @@ export function AppShell({
setTabs((prev) => {
const existing = prev.find((t) => t.id === id);
if (existing) {
// --- tmux-monitor --- refocusing with a host preselects it
// Refocusing a singleton with a host or data passes it on.
if (!host && data === undefined) return prev;
return prev.map((t) =>
t.id === id
@@ -1721,7 +1694,7 @@ export function AppShell({
type,
label,
openedAt: Date.now(),
...(host ? { host } : {}), // --- tmux-monitor ---
...(host ? { host } : {}),
...(data !== undefined ? { data } : {}),
},
];
@@ -2537,6 +2510,7 @@ export function AppShell({
},
openSingletonTab: (type, options) =>
openSingletonTab(type, undefined, undefined, options?.data),
connectHost: (host, type) => connectHost(host, type as TabType),
closeTab: (tabId) => closeTab(tabId),
renameTab: (tabId, label) => renameTab(tabId, label),
@@ -2697,35 +2671,6 @@ export function AppShell({
}}
onRenameTab={renameTab}
onReorderTabs={setTabs}
onJoinSharedSession={(session) => {
if (!session.shareId) return;
const existingHost = allHosts.find(
(h) => h.id === String(session.hostId),
);
const host: Host = existingHost ?? {
id: String(session.hostId),
name: session.hostName,
username: "",
ip: "",
port: 0,
folder: "",
online: false,
cpu: null,
ram: null,
lastAccess: new Date().toISOString(),
authType: "none",
enableSsh: false,
sshPort: 22,
};
void invokeAction("terminal.open", host, {
joinSharedSessionId: session.sessionId,
joinShareId: session.shareId,
label: t("connections.sharedSessionLabel", {
hostName: session.hostName,
}),
});
if (isMobile) setSidebarOpen(false);
}}
/>
</div>
)}
@@ -3037,12 +2982,6 @@ export function AppShell({
onReorderTabs={reorderTopLevelTabs}
onSplitAction={handleTabSplitAction}
onRenameTab={renameTab}
onOpenFileManager={(tabId) => {
const targetTab = tabs.find((t) => t.id === tabId);
if (targetTab?.host) {
void invokeAction("host.openFiles", targetTab.host);
}
}}
isAppFullscreen={isAppFullscreen}
onToggleAppFullscreen={toggleAppFullscreen}
rightDockOpen={rightRailView !== null}
+1 -1
View File
@@ -11,7 +11,7 @@ export interface PublicLoginMethod {
kind: "redirect" | "form";
labelKey: string;
icon?: string;
instances: Array<{ id: string; label: string }>;
instances: Array<{ id: string; label: string; autoStart?: boolean }>;
}
export interface SecondFactorRef {
+1 -1
View File
@@ -12,7 +12,7 @@ export interface UptimeInfo {
export interface RecentActivityItem {
id: number;
userId: string;
/** Core records file_manager and tunnel; plugin tabs record their own. */
/** The activity type; each plugin records its own. */
type: string;
hostId: number;
hostName: string;
+36
View File
@@ -0,0 +1,36 @@
import { authApi, handleApiError } from "@/main-axios";
// EXTERNAL ACCOUNT LINKING
// ============================================================================
export async function linkExternalToPasswordAccount(
externalUserId: string,
targetUsername: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await authApi.post("/users/link-external-to-password", {
externalUserId,
targetUsername,
});
return response.data;
} catch (error) {
throw handleApiError(error, "link external account to password account");
}
}
export async function unlinkExternalFromPasswordAccount(
userId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await authApi.post(
"/users/unlink-external-from-password",
{ userId },
);
return response.data;
} catch (error) {
throw handleApiError(
error,
"unlink external sign-in from password account",
);
}
}
-32
View File
@@ -1,32 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
// OIDC ACCOUNT LINKING
// ============================================================================
export async function linkOIDCToPasswordAccount(
oidcUserId: string,
targetUsername: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await authApi.post("/users/link-oidc-to-password", {
oidcUserId,
targetUsername,
});
return response.data;
} catch (error) {
throw handleApiError(error, "link OIDC account to password account");
}
}
export async function unlinkOIDCFromPasswordAccount(
userId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await authApi.post("/users/unlink-oidc-from-password", {
userId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "unlink OIDC from password account");
}
}
+3 -20
View File
@@ -1,9 +1,6 @@
import { authApi } from "@/main-axios";
import { createTtlRequestCache } from "@/lib/ttl-request-cache";
import type { CustomKeybinding } from "@/types/keybindings";
import { invokeAction, isActionRegistered } from "@/shell/action-registry";
const SHARED_WITH_ME_ACTION = "sessions.sharedWithMe";
// OPEN TABS API
// ============================================================================
@@ -45,10 +42,6 @@ export interface ActiveSessionInfo {
tabInstanceId: string | null;
isConnected: boolean;
createdAt: number;
isOwnSession: boolean;
sharedByUsername: string | null;
permissionLevel: string | null;
shareId: string | null;
}
// Negative IDs identify remote-only shared hosts, absent from the local DB.
@@ -86,21 +79,11 @@ export async function addOpenTab(tab: OpenTabUpsertPayload): Promise<void> {
await authApi.post("/open-tabs", tab);
}
/**
* The caller's live sessions, plus sessions other users shared with them,
* which the session sharing plugin answers through an action (none while it
* is off).
*/
/** The caller's own live sessions. */
export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
return activeSessionsCache.get(async () => {
const [response, shared] = await Promise.all([
authApi.get("/open-tabs/active-sessions"),
isActionRegistered(SHARED_WITH_ME_ACTION)
? invokeAction(SHARED_WITH_ME_ACTION).catch(() => [])
: Promise.resolve([]),
]);
const own = Array.isArray(response.data) ? response.data : [];
return Array.isArray(shared) ? [...own, ...shared] : own;
const response = await authApi.get("/open-tabs/active-sessions");
return Array.isArray(response.data) ? response.data : [];
});
}
+4 -13
View File
@@ -78,19 +78,10 @@ export interface PluginContributions {
uiPresets?: PluginUiPresets;
}
/** A secret as it arrives from the server. The value never leaves the server. */
export interface RedactedSecret {
set: boolean;
}
export function isRedactedSecret(value: unknown): value is RedactedSecret {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
typeof (value as RedactedSecret).set === "boolean"
);
}
export {
isRedactedSecret,
type RedactedSecret,
} from "@termix/plugin-sdk/settings";
export type PluginSettingsValues = Record<string, unknown>;
+1 -1
View File
@@ -76,7 +76,7 @@ export async function updateAnalyticsEnabled(
}
// ============================================================================
// TERMINAL IMAGE STORAGE SETTINGS
// BRANDING SETTINGS
// ============================================================================
export interface BrandingSettings {
-46
View File
@@ -1,46 +0,0 @@
export interface ServerSentEvent {
event: string;
data: string;
}
export async function streamServerSentEvents(
url: string,
init: RequestInit,
onEvent: (event: ServerSentEvent) => void,
): Promise<void> {
const response = await fetch(url, init);
if (!response.ok) {
throw new Error(`SSE request failed with status ${response.status}`);
}
if (!response.body) throw new Error("SSE response has no body");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let eventName = "message";
let data: string[] = [];
const dispatch = () => {
if (data.length > 0) onEvent({ event: eventName, data: data.join("\n") });
eventName = "message";
data = [];
};
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
const lines = buffer.split(/\r?\n/);
buffer = done ? "" : (lines.pop() ?? "");
for (const line of lines) {
if (line === "") dispatch();
else if (line.startsWith("event:")) eventName = line.slice(6).trimStart();
else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
}
if (done) {
dispatch();
return;
}
}
}
+6 -29
View File
@@ -221,20 +221,22 @@ export async function updateRegistrationAllowed(
}
}
export async function getOidcAutoProvision(): Promise<{ enabled: boolean }> {
export async function getExternalAutoProvision(): Promise<{
enabled: boolean;
}> {
try {
const response = await authApi.get("/users/oidc-auto-provision");
const response = await authApi.get("/users/external-auto-provision");
return response.data;
} catch (error) {
handleApiError(error, "check OIDC auto-provision status");
}
}
export async function updateOidcAutoProvision(
export async function updateExternalAutoProvision(
enabled: boolean,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.patch("/users/oidc-auto-provision", {
const response = await authApi.patch("/users/external-auto-provision", {
enabled,
});
return response.data;
@@ -270,31 +272,6 @@ export async function updateSecondFactorAfterExternalLogin(
}
}
export async function getOidcSilentLoginDefault(): Promise<{
enabled: boolean;
locked?: boolean;
}> {
try {
const response = await authApi.get("/users/oidc-silent-login-default");
return response.data;
} catch (error) {
handleApiError(error, "get OIDC silent login default");
}
}
export async function updateOidcSilentLoginDefault(
enabled: boolean,
): Promise<{ enabled: boolean }> {
try {
const response = await authApi.patch("/users/oidc-silent-login-default", {
enabled,
});
return response.data;
} catch (error) {
handleApiError(error, "update OIDC silent login default");
}
}
export async function updatePasswordLoginAllowed(
allowed: boolean,
): Promise<{ allowed: boolean }> {
+24 -27
View File
@@ -27,7 +27,6 @@ import {
completePasswordReset,
isElectron,
getCurrentToken,
getOidcSilentLoginDefault,
requestDesktopAutoSession,
requestTrustedProxyLogin,
} from "@/main-axios";
@@ -343,9 +342,6 @@ export function Auth({ onLogin }: AuthProps) {
);
const silentSigninHandledRef = useRef(false);
const proxySigninHandledRef = useRef(false);
const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false);
const [oidcSilentLoginDefaultLoaded, setOidcSilentLoginDefaultLoaded] =
useState(false);
const [firstUser, setFirstUser] = useState(false);
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
const [dbHealthChecking, setDbHealthChecking] = useState(
@@ -401,7 +397,7 @@ export function Auth({ onLogin }: AuthProps) {
useEffect(() => {
if (!isInElectronWebView()) return;
const handleOIDCSystemBrowserResult = (event: MessageEvent) => {
const handleExternalBrowserResult = (event: MessageEvent) => {
if (event.source !== window.parent) return;
if (!event.data || typeof event.data !== "object") return;
if (event.data.type !== "OIDC_SYSTEM_BROWSER_AUTH_RESULT") return;
@@ -418,15 +414,14 @@ export function Auth({ onLogin }: AuthProps) {
toast.error(event.data.error || t("errors.failedOidcLogin"));
};
window.addEventListener("message", handleOIDCSystemBrowserResult);
window.addEventListener("message", handleExternalBrowserResult);
return () =>
window.removeEventListener("message", handleOIDCSystemBrowserResult);
window.removeEventListener("message", handleExternalBrowserResult);
}, [t]);
useEffect(() => {
if (localDesktopAuth) {
setAuthMethodsLoaded(true);
setOidcSilentLoginDefaultLoaded(true);
return;
}
getRegistrationAllowed()
@@ -444,10 +439,6 @@ export function Auth({ onLogin }: AuthProps) {
.then((methods) => setAuthMethods(methods))
.catch(() => setAuthMethods([]))
.finally(() => setAuthMethodsLoaded(true));
getOidcSilentLoginDefault()
.then((res) => setOidcSilentLoginDefault(res.enabled))
.catch(() => {})
.finally(() => setOidcSilentLoginDefaultLoaded(true));
}, [localDesktopAuth]);
useEffect(() => {
@@ -997,7 +988,7 @@ export function Auth({ onLogin }: AuthProps) {
const electronAPI = (
window as unknown as {
electronAPI?: {
oidcSystemBrowserAuth?: (
externalBrowserLogin?: (
authUrl: string,
port: number,
) => Promise<{
@@ -1011,14 +1002,14 @@ export function Auth({ onLogin }: AuthProps) {
};
}
).electronAPI;
if (electronAPI?.oidcSystemBrowserAuth) {
if (electronAPI?.externalBrowserLogin) {
const authUrl = await startLoginRedirect(methodId, {
instanceId,
rememberMe,
desktopCallbackPort: callbackPort,
});
if (!authUrl) throw new Error(t("errors.invalidAuthUrl"));
const result = await electronAPI.oidcSystemBrowserAuth(
const result = await electronAPI.externalBrowserLogin(
authUrl,
callbackPort,
);
@@ -1093,10 +1084,18 @@ export function Auth({ onLogin }: AuthProps) {
useEffect(() => {
if (!authMethodsLoaded || silentSigninHandledRef.current) return;
if (!oidcSilentLoginDefaultLoaded) return;
// A login method may ask to start without a click (the sso plugin's
// silent sign-in setting); ?silent_signin asks for it once.
const autoStart = externalMethods
.filter((method) => method.kind === "redirect")
.flatMap((method) =>
method.instances
.filter((instance) => instance.autoStart)
.map((instance) => ({ method, instance })),
)[0];
const urlTriggered = shouldTriggerSilentSignin(window.location.search);
if (!urlTriggered && !oidcSilentLoginDefault) return;
if (!urlTriggered && !autoStart) return;
if (urlTriggered) {
const nextSearch = removeSilentSigninFromSearch(window.location.search);
@@ -1112,22 +1111,20 @@ export function Auth({ onLogin }: AuthProps) {
const redirectMethod = externalMethods.find(
(method) => method.kind === "redirect" && method.instances.length > 0,
);
if (redirectMethod && !isElectron()) {
void startRedirect(redirectMethod.id, redirectMethod.instances[0].id);
const target =
autoStart ??
(redirectMethod
? { method: redirectMethod, instance: redirectMethod.instances[0] }
: null);
if (target && !isElectron()) {
void startRedirect(target.method.id, target.instance.id);
return;
}
if (urlTriggered) {
toast.info(t("errors.silentSigninOidcUnavailable"));
}
}, [
startRedirect,
authMethodsLoaded,
externalMethods,
t,
oidcSilentLoginDefault,
oidcSilentLoginDefaultLoaded,
]);
}, [startRedirect, authMethodsLoaded, externalMethods, t]);
// Electron, non-iframed: wait for the auto-session probe before rendering
// anything, so a standalone desktop install never flashes a login form
+3 -3
View File
@@ -121,7 +121,7 @@ export function ElectronLoginForm({
const electronAPI = (
window as unknown as {
electronAPI?: {
oidcSystemBrowserAuth?: (
externalBrowserLogin?: (
url: string,
port: number,
) => Promise<{
@@ -135,14 +135,14 @@ export function ElectronLoginForm({
};
}
).electronAPI;
if (!electronAPI?.oidcSystemBrowserAuth) {
if (!electronAPI?.externalBrowserLogin) {
sendResultToIframe({
success: false,
error: t("errors.failedOidcLogin"),
});
return;
}
const result = await electronAPI.oidcSystemBrowserAuth(
const result = await electronAPI.externalBrowserLogin(
authUrl,
callbackPort,
);
@@ -48,7 +48,7 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
}, []);
useEffect(() => {
// Popped-out windows (terminal/tunnel/file-manager/etc.) share the main
// Popped-out plugin windows share the main
// window's session rather than owning their own login -- there's no
// login form to fall back to here. On a cold Electron launch this
// window's renderer can start before the embedded backend has finished
@@ -1,40 +1,2 @@
import type { ConnectionStage } from "@/types/connection-log.ts";
export type ConnectionStatus =
"connecting" | "connected" | "error" | "disconnected";
// Guacamole client states, per guacamole-common-js Guacamole.Client#STATE_*.
const GUAC_STATE_IDLE = 0;
const GUAC_STATE_CONNECTING = 1;
const GUAC_STATE_WAITING = 2;
const GUAC_STATE_CONNECTED = 3;
const GUAC_STATE_DISCONNECTING = 4;
const GUAC_STATE_DISCONNECTED = 5;
export function guacStateToStage(state: number): ConnectionStage {
switch (state) {
case GUAC_STATE_CONNECTING:
return "guac_connecting";
case GUAC_STATE_WAITING:
return "guac_handshake";
case GUAC_STATE_CONNECTED:
return "guac_ready";
case GUAC_STATE_DISCONNECTING:
case GUAC_STATE_DISCONNECTED:
return "guac_disconnected";
case GUAC_STATE_IDLE:
default:
return "guac_connecting";
}
}
export function guacStateToStatus(state: number): ConnectionStatus {
switch (state) {
case GUAC_STATE_CONNECTED:
return "connected";
case GUAC_STATE_DISCONNECTED:
return "error";
default:
return "connecting";
}
}
+2 -2
View File
@@ -484,9 +484,9 @@ export function HostStatusCard({
</div>
</div>
<div className="flex shrink-0 items-center gap-3">
{/* Plugins such as host metrics show live usage here. */}
{/* Plugins add live details to a host row here. */}
<ComponentSlot
slotId="dashboard.hostMetrics"
slotId="dashboard.hostRow"
props={{
hostId: Number(host.id),
online: availability === "online",
+4 -81
View File
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "./theme.css";
@import "@fontsource-variable/jetbrains-mono";
@import "@fontsource/jetbrains-mono/400.css";
@import "@fontsource/jetbrains-mono/400-italic.css";
@@ -12,6 +13,9 @@
@import "@fontsource/source-code-pro/700.css";
@import "@fontsource/source-code-pro/700-italic.css";
/* Plugins compile their own classes (termix-plugin build). */
@source not "../../plugins";
@font-face {
font-family: "Caskaydia Cove Nerd Font Mono";
src: url("/fonts/CaskaydiaCoveNerdFontMono-Regular.ttf") format("truetype");
@@ -44,61 +48,6 @@
font-display: swap;
}
@custom-variant dark (&:is(.dark *));
@custom-variant dracula (&:is(.dracula *));
@custom-variant catppuccin (&:is(.catppuccin *));
@custom-variant nord (&:is(.nord *));
@custom-variant solarized (&:is(.solarized *));
@custom-variant tokyo-night (&:is(.tokyo-night *));
@custom-variant one-dark (&:is(.one-dark *));
@custom-variant gruvbox (&:is(.gruvbox *));
@theme inline {
--font-heading: var(--font-mono);
--font-mono: var(--font-ui, "JetBrains Mono Variable", monospace);
--color-accent-brand: var(--accent-brand);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--color-surface: var(--surface);
--color-surface-dim: var(--surface-dim);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--accent-brand: #f59145;
--motion-duration-micro: 110ms;
@@ -633,28 +582,6 @@ html.fs-xl {
}
}
@utility scrollbar-none {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
@utility thin-scrollbar {
scrollbar-width: thin;
scrollbar-color: oklch(1 0 0 / 15%) transparent;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: oklch(1 0 0 / 15%);
border-radius: 2px;
}
}
@keyframes blink {
0%,
100% {
@@ -665,10 +592,6 @@ html.fs-xl {
}
}
.terminal-toolbar-collapsed-shell {
position: relative;
}
/* Motion communicates state and hierarchy. Keep it off the terminal glyph layer. */
.motion-interactive {
transition-duration: var(--motion-duration-micro);
-6
View File
@@ -165,10 +165,4 @@ export async function applyPresetSideEffects(
JSON.stringify(buildDashboardSlots(target.dashboard.enabledCards)),
);
window.dispatchEvent(new Event("dashboardSlotsChanged"));
writeLocal("termix-terminal-toolbar-density", target.terminal.toolbarDensity);
window.dispatchEvent(new Event("terminalToolbarDensityChanged"));
writeLocal("fileManagerViewMode", target.fileManager.viewMode);
window.dispatchEvent(new Event("fileManagerViewModeChanged"));
}
-4
View File
@@ -4,7 +4,6 @@ export interface LogContext {
operation?: string;
userId?: string;
hostId?: number;
tunnelName?: string;
sessionId?: string;
requestId?: string;
duration?: number;
@@ -53,7 +52,6 @@ class FrontendLogger {
if (context.operation) contextParts.push(context.operation);
if (context.userId) contextParts.push(`user:${context.userId}`);
if (context.hostId) contextParts.push(`host:${context.hostId}`);
if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`);
if (context.sessionId) contextParts.push(`session:${context.sessionId}`);
if (context.responseTime) contextParts.push(`${context.responseTime}ms`);
if (context.status) contextParts.push(`status:${context.status}`);
@@ -372,8 +370,6 @@ export const apiLogger = new FrontendLogger("API", "🌐", "#3b82f6");
export const authLogger = new FrontendLogger("AUTH", "🔐", "#dc2626");
export const sshLogger = new FrontendLogger("SSH", "🖥️", "#1e3a8a");
export const fileLogger = new FrontendLogger("FILE", "📁", "#1e3a8a");
export const statsLogger = new FrontendLogger("STATS", "📊", "#22c55e");
const systemLogger = new FrontendLogger("SYSTEM", "🚀", "#1e3a8a");
export const dashboardLogger = new FrontendLogger("DASHBOARD", "📊", "#ec4899");
/** A named console logger, for plugins that want the same log format. */
+1 -1
View File
@@ -14,7 +14,7 @@ export const DASHBOARD_CARDS: DashboardCardConfig[] = [
{
id: "counters_bar",
label: "Counters Bar",
description: "Total hosts, credentials, and tunnels count",
description: "Host and credential totals, plus any counters plugins add",
defaultEnabled: true,
},
{
+35 -119
View File
@@ -257,7 +257,6 @@
"nav": {
"home": "Home",
"terminal": "Terminal",
"localTerminal": "Local Terminal",
"fileManager": "File Manager",
"serverStats": "Host Metrics",
"admin": "Admin",
@@ -274,11 +273,8 @@
"failedToCopyPassword": "Failed to copy password",
"refreshTab": "Refresh connection",
"renameTab": "Rename tab",
"openFileManager": "Open File Manager",
"dashboard": "Dashboard",
"quickConnect": "Quick Connect",
"sshTools": "SSH Tools",
"history": "History",
"openAsTabHint": "Ctrl+click or middle click to open as a tab",
"openAsTab": "Open as tab",
"openInRightDock": "Open in right dock",
@@ -320,9 +316,6 @@
"addHost": "Add Host",
"editHost": "Edit Host",
"cloneHost": "Clone Host",
"enableTunnel": "Enable Tunnel",
"enableFileManager": "Enable File Manager",
"enableDocker": "Enable Docker",
"defaultPath": "Default Path",
"connection": "Connection",
"upload": "Upload",
@@ -359,19 +352,17 @@
"behavior": "Behavior",
"scrollbackBuffer": "Scrollback Buffer",
"sshAgentForwarding": "SSH Agent Forwarding",
"startupSnippet": "Startup Snippet",
"selectSnippet": "Select snippet",
"forceKeyboardInteractive": "Force Keyboard-Interactive",
"overrideCredentialUsername": "Override Credential Username",
"overrideCredentialUsernameDesc": "Use the username specified above instead of the credential's username",
"oidcUsernameHint": "Use $oidc.preferred_username to substitute your OIDC login name.",
"oidcUsernameHint": "Use $external.username to fill in the name you signed in with through SSO or LDAP.",
"jumpHostChain": "Jump Host Chain",
"portKnocking": "Port Knocking",
"addKnock": "Add Port",
"addProxyNode": "Add Node",
"proxyNode": "Proxy Node",
"proxyType": "Proxy Type",
"quickActions": "Quick Actions",
"sudoPassword": "Sudo Password",
"keepaliveInterval": "Keepalive Interval (ms)",
"environmentVariables": "Environment Variables",
@@ -468,11 +459,6 @@
"keepaliveIntervalLabel": "Keepalive Interval (seconds)",
"maxKeepaliveMisses": "Max Keepalive Misses",
"sameHost": "This host (direct tunnel)",
"proxmoxLastSyncStatus": {
"success": "Success"
},
"proxmoxResultSkippedNoIp": "{{count}} skipped (no IP found)",
"proxmoxStatsUrlCopied": "Proxmox Stats URL copied to clipboard",
"statusChecksLabel": "Status Checks",
"enableStatusChecks": "Enable Status Checks",
"enableStatusChecksDesc": "Periodically ping this host to verify availability",
@@ -634,8 +620,6 @@
"openInNewTab": "Open in new tab",
"deletedHost": "Deleted {{name}}",
"copiedToClipboard": "Copied to clipboard",
"rdpUrlCopied": "RDP URL copied",
"vncUrlCopied": "VNC URL copied",
"remoteDesktopUrlCopied": "Remote Desktop URL copied",
"expandActions": "Expand actions",
"collapseActions": "Collapse actions",
@@ -755,7 +739,6 @@
"filterAuthKey": "SSH Key",
"filterAuthCredential": "Credential",
"filterAuthNone": "None",
"filterAuthStepca": "Step CA",
"filterProtocolGroup": "Protocol",
"filterProtocolSsh": "SSH",
"filterTagsGroup": "Tags",
@@ -843,24 +826,6 @@
"authOverrideLoadError": "Failed to load your SSH authentication. Please try again.",
"authOverrideSaveError": "Failed to save your SSH authentication"
},
"guac": {
"noCredential": "No credential (direct credentials below)",
"authTypeDirect": "Direct",
"authTypeCredential": "Credential",
"authTypeNone": "None",
"recordingPath": "Recording Path",
"recordingName": "Recording Name",
"saveHostFirst": "Save the host first.",
"sharingOptionsAfterSave": "Sharing options are available after the host has been saved.",
"permissionLevel": "Permission Level",
"typeHeader": "Type",
"targetHeader": "Target",
"permissionHeader": "Permission",
"cancelBtn": "Cancel",
"savingBtn": "Saving...",
"updateHostBtn": "Update Host",
"addHostBtn": "Add Host"
},
"arrangeUnlockedSwitchedToManual": "Switched to manual sort so your order sticks",
"dragToRearrange": "Drag to rearrange",
"arrangeUnlocked": "Unlock drag to rearrange",
@@ -872,7 +837,6 @@
"webUiErrorPathInvalid": "Endpoint {{row}} has an invalid path. Use a path like /admin.",
"copyViewUrlAction": "Copy {{name}} link",
"filterAuthAgent": "SSH Agent",
"filterAuthVault": "Vault",
"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.",
@@ -883,7 +847,11 @@
"localOnlyDesc": "This host is not sent to the server and does not appear on your other devices.",
"folderLocalOnly": "Keep on this device only",
"folderLocalOnlyDesc": "This folder and every host in it stay on this device and are not sent to the server.",
"sudoPasswordDesc": "Used when the terminal fills a sudo prompt, and by features that run commands with sudo."
"sudoPasswordDesc": "Used when the terminal fills a sudo prompt, and by features that run commands with sudo.",
"editorCancel": "Cancel",
"editorSaving": "Saving...",
"editorUpdate": "Update Host",
"editorAdd": "Add Host"
},
"commandPalette": {
"searchPlaceholder": "Search hosts, commands, or settings...",
@@ -980,23 +948,9 @@
"expiresIn": "Expires in {{duration}}",
"search": "Search connections...",
"noSearchResults": "No connections match your search",
"rename": "Rename session",
"sectionSharedWithMe": "Shared with me",
"sharedBy": "Shared by {{username}}",
"join": "Join",
"sharedSessionLabel": "{{hostName}} (shared)"
},
"sessionSharing": {
"permissionLevel": {
"readOnly": "Read-only",
"readWrite": "Read-write"
}
"rename": "Rename session"
},
"terminal": {
"connect": "Connect to Host",
"clear": "Clear",
"paste": "Paste",
"tmuxDetach": "Detach from tmux session",
"totpRequired": "Two-Factor Authentication Required",
"totpCodeLabel": "Verification Code",
"totpVerify": "Verify",
@@ -1011,7 +965,6 @@
"browserSignInOpen": "Open in Browser",
"browserSignInContinue": "I've Completed Sign-in",
"browserSignInRequired": "{{provider}} Sign-in Required",
"sudoPasswordPopupTitle": "Insert Password?",
"connectionLogTitle": "Connection Log",
"connectionLogCopy": "Copy logs to clipboard",
"connectionLogEmpty": "No connection logs yet",
@@ -1019,41 +972,6 @@
"connectionLogCopied": "Connection logs copied to clipboard",
"connectionLogCopyFailed": "Failed to copy logs to clipboard"
},
"transfer": {
"collapseRecentDestinations": "Collapse recent destinations",
"expandRecentDestinations": "Expand recent destinations",
"browseDestination": "Browse or enter path",
"transferring": "Transferring…",
"compressing": "Compressing…",
"extracting": "Extracting…",
"transferringItems": "Transferring {{current}} of {{total}} items…",
"transferPartial": "Transfer completed with {{count}} errors",
"selectThisFolder": "Select this folder",
"metricsTitle": "Transfer timings",
"metricsHopSourceRead": "Source → server: {{throughput}}",
"metricsHopDestSftpWrite": "Server → dest (SFTP): {{throughput}}",
"metricsHopDestLocalWrite": "Server → dest (local): {{throughput}}",
"sourceNotDeletedPartial": "Source files kept (partial transfer)"
},
"tunnels": {
"connect": "Connect",
"disconnect": "Disconnect",
"bindIp": "Local IP",
"remoteAddress": "Remote Address",
"endpointSshConfig": "Endpoint SSH Configuration",
"clientTunnel": "Client Tunnel",
"addClientTunnel": "Add Client Tunnel",
"typeServerLocalDesc": "Current host to endpoint.",
"typeServerRemoteDesc": "Endpoint back to current host.",
"typeClientLocalDesc": "Local computer to endpoint.",
"typeClientRemoteDesc": "Endpoint back to local computer.",
"typeClientDynamicDesc": "SOCKS on local computer.",
"typeDynamicDesc": "Forward SOCKS5 CONNECT traffic through SSH",
"maxRetriesDescription": "Maximum amount of retry attempts.",
"retryIntervalDescription": "Time to wait between retry attempts.",
"local": "Local",
"remote": "Remote"
},
"cardGrid": {
"dragToMove": "Drag to move",
"dragToResize": "Drag to resize",
@@ -1092,7 +1010,7 @@
"external": "External",
"loginWithExternal": "Login with External Provider",
"loginWithExternalDesc": "Login using your configured external identity provider",
"externalNotSupportedInElectron": "External authentication is not supported in the Electron app yet. Please use the web version for OIDC login.",
"externalNotSupportedInElectron": "External sign-in is not supported in the desktop app yet. Please use the web version.",
"loginWithProvider": "Login with {{name}}",
"orContinueWith": "or continue with",
"resetPasswordButton": "Reset Password",
@@ -1141,10 +1059,10 @@
"failedVerifyCode": "Failed to verify reset code",
"failedCompleteReset": "Failed to complete password reset",
"invalidTotpCode": "Invalid TOTP code",
"failedOidcLogin": "Failed to start OIDC login",
"silentSigninOidcUnavailable": "Silent sign-in was requested, but OIDC login is not available.",
"failedOidcLogin": "Failed to start external sign-in",
"silentSigninOidcUnavailable": "Silent sign-in was requested, but no external sign-in is set up.",
"failedUserInfo": "Failed to get user info after login",
"oidcAuthFailed": "OIDC authentication failed",
"oidcAuthFailed": "External sign-in failed",
"invalidAuthUrl": "Invalid authorization URL received from backend",
"requiredField": "This field is required",
"minLength": "Minimum length is {{min}}",
@@ -1172,10 +1090,6 @@
"loginSuccess": "Login successful",
"registrationSuccess": "Registration successful"
},
"profile": {
"c2sTunnelConfigDesc": "Local desktop tunnels targeting configured SSH hosts.",
"c2sTunnelPresetsDesc": "Save this desktop client's local tunnel list as a named server preset, or load a preset back into this client."
},
"placeholders": {
"language": "Language",
"keyPassword": "key password",
@@ -1200,7 +1114,6 @@
"healthy": "Healthy",
"error": "Error",
"totalHosts": "Total Hosts",
"totalTunnels": "Total Tunnels",
"totalCredentials": "Total Credentials",
"recentActivity": "Recent Activity",
"reset": "Reset",
@@ -1220,7 +1133,6 @@
"resetLayout": "Reset to Default",
"serverOverviewCard": "Server Overview",
"recentActivityCard": "Recent Activity",
"networkGraphCard": "Network Graph",
"quickActionsCard": "Quick Actions",
"serverStatsCard": "Server Stats",
"panelMain": "Main",
@@ -1319,9 +1231,6 @@
"oidcAutoProvisionDesc": "Create an account the first time someone signs in through an external login such as SSO or LDAP, even when registration is off",
"secondFactorAfterExternalLogin": "Ask for a second factor after external logins (SSO, GitHub, Google, LDAP)",
"secondFactorAfterExternalLoginDesc": "Off, an enrolled second factor is only asked for after password and other local logins, same as before. On, it is asked for after every login method, including SSO and LDAP.",
"oidcSilentLoginDefault": "Silent external login by default",
"oidcSilentLoginDefaultDesc": "Send visitors straight to the external login provider, skipping the login form",
"oidcSilentLoginDefaultLockedDesc": "This setting is set by the OIDC_SILENT_LOGIN_DEFAULT environment variable and cannot be changed here.",
"allowPasswordReset": "Allow Password Reset",
"allowPasswordResetDesc": "Reset code via Docker logs",
"analyticsEnabled": "Share Anonymous Usage Statistics",
@@ -1380,7 +1289,7 @@
"apiKeyNoExpiry": "No expiry",
"revokedBadge": "REVOKED",
"authTypeDual": "Dual Auth",
"authTypeOidc": "OIDC",
"authTypeOidc": "External",
"authTypeLocal": "Local",
"adminStatusAdministrator": "Administrator",
"adminStatusRegularUser": "Regular User",
@@ -1431,34 +1340,33 @@
"editUserAdminStatus": "Admin Status",
"editUserUserId": "User ID",
"linkAccountTitle": "Link Accounts",
"linkAccountDesc": "Merge the OIDC account {{username}} with an existing local account.",
"linkAccountDescLocal": "Link a local account {{username}} with an existing OIDC-only account.",
"linkAccountDesc": "Merge the external account {{username}} with an existing local account.",
"linkAccountDescLocal": "Link a local account {{username}} with an existing external-only account.",
"linkAccountWarningTitle": "This will:",
"linkAccountEffect1": "Delete the OIDC-only account",
"linkAccountEffect2": "Add OIDC login to the target account",
"linkAccountEffect3": "Allow both OIDC and password login",
"linkAccountEffect1": "Delete the external-only account",
"linkAccountEffect2": "Add its external sign-in to the target account",
"linkAccountEffect3": "Allow both external and password sign-in",
"linkAccountTargetUsername": "Local Account Username",
"linkAccountTargetPlaceholder": "Enter the local account username to link to",
"linkAccountOidcUsername": "OIDC Account Username",
"linkAccountOidcPlaceholder": "Enter the OIDC-only account username to merge in",
"linkAccountOidcNotFound": "No OIDC-only account found with that username",
"linkAccountOidcUsername": "External Account Username",
"linkAccountOidcPlaceholder": "Enter the external-only account username to merge in",
"linkAccountOidcNotFound": "No external-only account found with that username",
"linkAccounts": "Link Accounts",
"linkAccountSuccess": "Accounts linked successfully",
"linkAccountFailed": "Failed to link accounts",
"linkAccountInProgress": "Linking...",
"unlinkAccountTitle": "Unlink OIDC",
"unlinkAccountDesc": "Remove OIDC authentication from {{username}}. They will only be able to log in with their password.",
"unlinkAccountWarning": "This will remove OIDC login from this account. The user must have a password set to continue logging in.",
"unlinkAccount": "Unlink OIDC",
"unlinkAccountTitle": "Unlink External Sign-in",
"unlinkAccountDesc": "Remove external sign-in from {{username}}. They will only be able to log in with their password.",
"unlinkAccountWarning": "This will remove external sign-in from this account. The user must have a password set to keep logging in.",
"unlinkAccount": "Unlink External Sign-in",
"unlinkAccountInProgress": "Unlinking...",
"unlinkAccountSuccess": "OIDC unlinked successfully",
"unlinkAccountFailed": "Failed to unlink OIDC",
"unlinkAccountSuccess": "External sign-in unlinked",
"unlinkAccountFailed": "Failed to unlink external sign-in",
"saving": "Saving...",
"updateRegistrationFailed": "Failed to update registration setting",
"updatePasswordLoginFailed": "Failed to update password login setting",
"updateOidcAutoProvisionFailed": "Failed to update the auto-create setting",
"updateSecondFactorAfterExternalLoginFailed": "Failed to update second factor setting",
"updateOidcSilentLoginDefaultFailed": "Failed to update the silent login setting",
"updatePasswordResetFailed": "Failed to update password reset setting",
"sessionTimeoutRange2": "Session timeout must be between 1 and 720 hours",
"sessionTimeoutSaved": "Session timeout saved",
@@ -1795,7 +1703,7 @@
"importFailed": "Import failed: {{error}}",
"roleUser": "User",
"authMethodDual": "Dual Auth",
"authMethodOidc": "OIDC",
"authMethodOidc": "External",
"done": "Done",
"apiKeyNameRequired": "Key name is required",
"apiKeyCreated": "API key \"{{name}}\" created",
@@ -1910,6 +1818,14 @@
"title": "Open desktop windows",
"consequence": "Only in the Termix desktop app."
},
"users:impersonate": {
"title": "Act as other users",
"consequence": "Runs background work with another user's access, such as scheduled jobs. Every use is written to the audit log."
},
"db:core-refs": {
"title": "Read core account tables",
"consequence": "Can query users, roles and hosts directly from the database."
},
"hosts:read": {
"title": "See your host list",
"consequence": "Names and addresses for hosts you can already see."
+11 -10
View File
@@ -55,7 +55,6 @@ import {
authLogger,
sshLogger,
fileLogger,
statsLogger,
dashboardLogger,
type LogContext,
} from "@/lib/frontend-logger";
@@ -77,6 +76,8 @@ export interface AuthResponse {
is_admin?: boolean;
username?: string;
userId?: string;
is_external?: boolean;
/** 2.8 name for is_external. */
is_oidc?: boolean;
totp_enabled?: boolean;
requires_totp?: boolean;
@@ -93,6 +94,9 @@ export interface UserInfo {
userId: string;
username: string;
is_admin: boolean;
/** Signs in through an external login such as SSO or LDAP. */
is_external?: boolean;
/** 2.8 name for is_external. */
is_oidc: boolean;
is_dual_auth?: boolean;
password_hash?: string;
@@ -140,8 +144,6 @@ function getLoggerForService(serviceName: string) {
return sshLogger;
} else if (serviceName.includes("FILE") || serviceName.includes("file")) {
return fileLogger;
} else if (serviceName.includes("STATS") || serviceName.includes("stats")) {
return statsLogger;
} else if (serviceName.includes("AUTH") || serviceName.includes("auth")) {
return authLogger;
} else if (
@@ -958,6 +960,7 @@ export async function loginUser(
requires_totp: response.data.requires_totp,
temp_token: response.data.temp_token,
rememberMe: response.data.rememberMe,
is_external: response.data.is_external ?? response.data.is_oidc,
is_oidc: response.data.is_oidc,
totp_enabled: response.data.totp_enabled,
token: response.data.token,
@@ -1209,12 +1212,10 @@ export {
deleteUser,
deleteAccount,
updateRegistrationAllowed,
getOidcAutoProvision,
updateOidcAutoProvision,
getExternalAutoProvision,
updateExternalAutoProvision,
getSecondFactorAfterExternalLogin,
updateSecondFactorAfterExternalLogin,
getOidcSilentLoginDefault,
updateOidcSilentLoginDefault,
updatePasswordLoginAllowed,
getPasswordResetAllowed,
updatePasswordResetAllowed,
@@ -1291,9 +1292,9 @@ export {
// ============================================================================
export {
linkOIDCToPasswordAccount,
unlinkOIDCFromPasswordAccount,
} from "@/api/oidc-account-api";
linkExternalToPasswordAccount,
unlinkExternalFromPasswordAccount,
} from "@/api/external-account-api";
// ============================================================================
// RBAC MANAGEMENT
+11
View File
@@ -94,6 +94,16 @@ export function createPluginApp(
let disposed = false;
const track = (disposer: Disposer): Disposer => {
// An activate that outlived its timeout, or ran on after a disable, still
// registers things. Nothing will dispose them later, so undo them now.
if (disposed) {
try {
disposer();
} catch {
// A failing disposer must not throw into the plugin's activate.
}
return () => {};
}
bag.push(disposer);
return () => {
const index = bag.indexOf(disposer);
@@ -526,6 +536,7 @@ export function createPluginApp(
...tabsApi,
openTab: shell.openTab as TermixApp["tabs"]["openTab"],
openSingletonTab: shell.openSingletonTab,
connectHost: shell.connectHost as TermixApp["tabs"]["connectHost"],
closeTab: shell.closeTab,
onChange: (listener) => track(tabsApi.onChange(listener)),
onReady: (listener) => track(tabsApi.onReady(listener)),
+14
View File
@@ -1,4 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { isElectron } from "@/lib/electron";
import { readStatusColorScheme } from "@/hooks/use-status-color-scheme";
import { enabledHostProtocols } from "@/sidebar/host-protocols";
import { useHostActions } from "@/sidebar/host-contributions";
import { tabTypeForActivity, useTabTypes } from "@/shell/tab-registry";
@@ -430,6 +432,18 @@ export const pluginHostBridge: PluginHostBridge = {
(await getSSHHosts({ includeStatus: false })).map((host) =>
toPluginHostRecord(host, pluginId ?? null),
),
notifyHostsChanged: () => {
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
},
getHostStatusColorScheme: () => readStatusColorScheme(),
getLocalAuthToken: () => {
if (!isElectron()) return null;
try {
return localStorage.getItem("jwt");
} catch {
return null;
}
},
listCredentials: async () => {
const raw = await getCredentials();
const list = Array.isArray(raw)
+12
View File
@@ -3,7 +3,19 @@
import { getLinkedSession } from "@/lib/linked-server";
import { SYNC_CHANGED_EVENT } from "@/lib/sync-events";
type RemoteServerSource = () => string | null | Promise<string | null>;
let testSource: RemoteServerSource | null = null;
/** renderWithApp's stand-in for the linked server. */
export function setRemoteServerUrlForTesting(
source: RemoteServerSource | null,
): void {
testSource = source;
}
export async function remoteServerUrl(): Promise<string | null> {
if (testSource) return (await testSource()) ?? null;
return (await getLinkedSession())?.serverUrl ?? null;
}
+127 -48
View File
@@ -12,44 +12,127 @@
*/
// Primitives
export * from "@/components/alert";
export * from "@/components/alert-dialog";
export * from "@/components/badge";
export * from "@/components/button";
export * from "@/components/card";
export * from "@/components/checkbox";
export * from "@/components/dialog";
export * from "@/components/dropdown-menu";
export * from "@/components/input";
export * from "@/components/label";
export * from "@/components/password-input";
export * from "@/components/popover";
export * from "@/components/scroll-area";
export * from "@/components/select";
export * from "@/components/select2";
export * from "@/components/separator";
export * from "@/components/skeleton";
export * from "@/components/slider";
export * from "@/components/switch";
export * from "@/components/textarea";
export * from "@/components/tooltip";
export { Alert, AlertDescription, AlertTitle } from "@/components/alert";
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/alert-dialog";
export { Badge } from "@/components/badge";
export { Button, type ButtonProps } from "@/components/button";
export {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/card";
export { Checkbox } from "@/components/checkbox";
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from "@/components/dialog";
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
export { Input } from "@/components/input";
export { Label } from "@/components/label";
export { PasswordInput } from "@/components/password-input";
export {
Popover,
PopoverAnchor,
PopoverContent,
PopoverTrigger,
} from "@/components/popover";
export { ScrollArea, ScrollBar } from "@/components/scroll-area";
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/select";
export { Select2 } from "@/components/select2";
export { Separator } from "@/components/separator";
export { Skeleton } from "@/components/skeleton";
export { Slider } from "@/components/slider";
export { Switch } from "@/components/switch";
export { Textarea, type TextareaProps } from "@/components/textarea";
export {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/tooltip";
// Composites
export * from "@/components/section-card";
export * from "@/components/metric-card";
export * from "@/components/charts";
export { FakeSwitch, SectionCard, SettingRow } from "@/components/section-card";
export { MetricCard } from "@/components/metric-card";
export {
BarSeries,
MiniStat,
RadialGauge,
Sparkline,
StatRow,
type BarSeriesItem,
type BarSeriesProps,
type RadialGaugeProps,
type SparklineProps,
} from "@/components/charts";
export { LineChart, type LineChartSeries } from "@/components/charts/LineChart";
export {
CardGridCanvas,
ColumnCountStepper,
} from "@/components/card-grid/CardGridCanvas";
export type * from "@/components/card-grid/types";
export {
type GridCardCatalogEntry,
type GridColSpan,
type GridLayout,
type GridSlot,
} from "@/components/card-grid/types";
export { ConnectionScreen } from "@/components/connection/ConnectionScreen";
export * from "@/components/connection/connection-status";
export { type ConnectionStatus } from "@/components/connection/connection-status";
export {
FullScreenAppWrapper,
type FullScreenAppPhase,
} from "@/features/FullScreenAppWrapper";
} from "@/components/FullScreenAppWrapper";
// Connection surfaces
export {
@@ -87,7 +170,16 @@ export {
runAdaptivePolling,
getPollingEnvironmentMultiplier,
} from "@/lib/adaptive-polling";
export * from "@/components/sheet";
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/sheet";
// The frame and filter box every host manager card uses.
export {
ManagerCardShell,
@@ -116,7 +208,12 @@ export { useTheme as useAppTheme } from "@/components/theme-provider";
export { findMatchingKeybinding } from "@/lib/keybinding-match";
export { globalShortcutHandler } from "@/lib/global-shortcut-handler";
export { isMacPlatform, isTabJumpHotkey } from "@/lib/tab-jump-hotkey";
export type * from "@/types/keybindings";
export {
type CustomKeybinding,
type KeyCombo,
type KeybindingAction,
type KeybindingActionType,
} from "@/types/keybindings";
// Connection helpers: which backend a host's session dials, and the pieces
// the desktop app needs to reach it.
@@ -170,22 +267,4 @@ export {
export { getDeviceId } from "@/lib/device-id";
// A host that is never saved, for connecting to an address straight away.
export {
createQuickConnectHost,
isQuickConnectHost,
} from "@/sidebar/quick-connect-host";
// The desktop app's local file bridge (window.electronAPI.localFs and
// localTransfer), for plugins that browse or transfer local files.
export type {
LocalCollectedFile,
LocalDirectoryListing,
LocalFileEntry,
LocalFsHomeInfo,
LocalFsResult,
LocalTransferOrigin,
LocalTransferProgress,
LocalTrashResult,
LocalWalkFile,
LocalWalkResult,
} from "@/types/electron";
export { createQuickConnectHost } from "@/sidebar/quick-connect-host";
+3
View File
@@ -107,6 +107,8 @@ export function shellHost(id: string | number | undefined): Host | undefined {
export const shell: TabShellCallbacks = {
openTab: (host, ...rest) => callbacks?.openTab(withShellHost(host), ...rest),
openSingletonTab: (...args) => callbacks?.openSingletonTab(...args),
connectHost: (host, ...rest) =>
callbacks?.connectHost(withShellHost(host), ...rest),
closeTab: (...args) => callbacks?.closeTab(...args),
renameTab: (...args) => callbacks?.renameTab(...args),
openRailView: (...args) => callbacks?.openRailView(...args),
@@ -119,6 +121,7 @@ export const shell: TabShellCallbacks = {
export const tabsApi = {
openTab: shell.openTab,
openSingletonTab: shell.openSingletonTab,
connectHost: shell.connectHost,
closeTab: shell.closeTab,
openRailView: shell.openRailView,
getLayout: () => layoutProvider?.getLayout() ?? null,
+4
View File
@@ -60,6 +60,7 @@ import { ActionSlot, ComponentSlot } from "@/shell/ActionSlot";
import type { Host, Tab } from "@/types/ui-types";
import { createPluginApp } from "./app";
import { installPluginHostBridge, setPluginApiForTesting } from "./bridge";
import { setRemoteServerUrlForTesting } from "./desktop";
import { setPluginSummaries, setFrontendState } from "./plugin-store";
import {
notifyShellReady,
@@ -81,6 +82,7 @@ function recordingShell(calls: ShellCall[]): TabShellCallbacks {
return {
openTab: record("openTab"),
openSingletonTab: record("openSingletonTab"),
connectHost: record("connectHost"),
closeTab: record("closeTab"),
renameTab: record("renameTab"),
openRailView: record("openRailView"),
@@ -187,6 +189,7 @@ export async function renderPlugin(
pluginId,
(options.api as Parameters<typeof setPluginApiForTesting>[1]) ?? null,
);
setRemoteServerUrlForTesting(options.remoteServerUrl ?? null);
const handle = createPluginApp(pluginId, manifest, summary.contributes, {
guest: options.guest,
});
@@ -407,6 +410,7 @@ export async function renderPlugin(
handle.dispose();
setFrontendState(pluginId, "inactive");
setPluginApiForTesting(pluginId, null);
setRemoteServerUrlForTesting(null);
}
},
};
+2 -2
View File
@@ -25,7 +25,7 @@ import {
type PluginSummary,
} from "@/api/plugins-api";
import { SettingsFieldRow } from "./SettingsFields";
import { hasVisibleFields, isFieldActive } from "./settings-fields-util";
import { hasVisibleFields, isFieldShown } from "./settings-fields-util";
export type FeatureSettingsScope = "admin" | "user";
@@ -187,7 +187,7 @@ function FeatureSettingsForm({
const rows = (group: PluginSettingsField[]) =>
group
.filter((field) => isFieldActive(field, values))
.filter((field) => isFieldShown(field, values))
.map((field) => (
<SettingsFieldRow
key={field.key}
+2 -2
View File
@@ -17,7 +17,7 @@ import { PluginIcon } from "@/lib/plugin-icon";
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
import { usePluginScope } from "@/plugin-host/scope";
import { SettingsFieldRow } from "./SettingsFields";
import { hasVisibleFields, isFieldActive } from "./settings-fields-util";
import { hasVisibleFields, isFieldShown } from "./settings-fields-util";
/** Values for every plugin on one host: { [pluginId]: { [key]: value } }. */
export type HostPluginSettings = Record<string, Record<string, unknown>>;
@@ -202,7 +202,7 @@ function HostPluginSection({
{enabled &&
host.fields
.filter((field) => isFieldActive(field, shown))
.filter((field) => isFieldShown(field, shown))
.map((field) => (
<SettingsFieldRow
key={field.key}
+3 -4
View File
@@ -1,3 +1,4 @@
import { isFieldActive } from "@termix/plugin-sdk/settings";
import type { PluginSettingsField } from "@/api/plugins-api";
/**
@@ -7,13 +8,11 @@ import type { PluginSettingsField } from "@/api/plugins-api";
* Kept out of the component file so both the settings page and the host editor
* section can import it without dragging a component along.
*/
export function isFieldActive(
export function isFieldShown(
field: PluginSettingsField,
values: Record<string, unknown>,
): boolean {
if (field.hidden) return false;
if (!field.requires) return true;
return values[field.requires] === true;
return !field.hidden && isFieldActive(field, values);
}
/** Whether a plugin has any field the generic form would ever draw. */
-18
View File
@@ -26,7 +26,6 @@ import {
Ungroup,
Maximize2,
Minimize2,
FolderOpen,
PanelRight,
} from "lucide-react";
import { tabIcon } from "@/shell/tabUtils";
@@ -71,7 +70,6 @@ export function TabBar({
onReorderTabs,
onSplitAction,
onRenameTab,
onOpenFileManager,
isAppFullscreen,
onToggleAppFullscreen,
rightDockOpen,
@@ -90,7 +88,6 @@ export function TabBar({
onReorderTabs: (tabs: Tab[]) => void;
onSplitAction: (action: TabSplitAction) => void;
onRenameTab?: (tabId: string, newLabel: string) => void;
onOpenFileManager?: (tabId: string) => void;
isAppFullscreen: boolean;
onToggleAppFullscreen: () => void;
rightDockOpen?: boolean;
@@ -712,21 +709,6 @@ export function TabBar({
{t("nav.refreshTab")}
</button>
)}
{typeof ctxTab.terminalRef?.current?.openFileManager ===
"function" &&
ctxTab.host &&
onOpenFileManager && (
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
onClick={() => {
onOpenFileManager(contextTabId);
setContextTabId(null);
}}
>
<FolderOpen className="size-3" />
{t("nav.openFileManager")}
</button>
)}
{/* Plugins add entries here, invoked with the tab's surface handle. */}
<ActionSlot
slotId="tab.menu"
-11
View File
@@ -36,8 +36,6 @@ interface TabContextType {
},
) => void;
updateTab: (tabId: number, updates: Partial<Omit<Tab, "id">>) => void;
previewTerminalTheme: string | null;
setPreviewTerminalTheme: (theme: string | null) => void;
}
const TabContext = createContext<TabContextType | undefined>(undefined);
@@ -62,8 +60,6 @@ const NOOP_TABS: TabContextType = {
reorderTabs: () => {},
updateHostConfig: () => {},
updateTab: () => {},
previewTerminalTheme: null,
setPreviewTerminalTheme: () => {},
};
export function useTabsSafe(): TabContextType {
@@ -86,9 +82,6 @@ export function TabProvider({ children }: TabProviderProps) {
]);
const [currentTab, setCurrentTab] = useState<number>(1);
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
const [previewTerminalTheme, setPreviewTerminalTheme] = useState<
string | null
>(null);
const [initialMaxId] = useState(2);
const nextTabId = useRef(initialMaxId);
@@ -372,8 +365,6 @@ export function TabProvider({ children }: TabProviderProps) {
reorderTabs,
updateHostConfig,
updateTab,
previewTerminalTheme,
setPreviewTerminalTheme,
}),
[
tabs,
@@ -386,8 +377,6 @@ export function TabProvider({ children }: TabProviderProps) {
reorderTabs,
updateHostConfig,
updateTab,
previewTerminalTheme,
setPreviewTerminalTheme,
],
);
+7 -5
View File
@@ -66,8 +66,6 @@ export function buildLayoutTabSnapshots(
hostNameSnapshot: tab.host?.name ?? null,
label: tab.label,
customLabel: tab.customLabel,
initialFilePath: tab.initialFilePath,
initialPath: tab.initialPath,
data: tab.data,
}));
@@ -82,9 +80,13 @@ export function snapshotData(
snapshot: WorkspaceTabSnapshot,
): Record<string, unknown> | undefined {
if (snapshot.data) return snapshot.data;
return snapshot.fleetId !== undefined
? { fleetId: snapshot.fleetId }
: undefined;
// Payloads saved before tabs carried `data` kept these as fields of their own.
const legacy: Record<string, unknown> = {};
if (snapshot.fleetId !== undefined) legacy.fleetId = snapshot.fleetId;
if (snapshot.initialFilePath)
legacy.initialFilePath = snapshot.initialFilePath;
if (snapshot.initialPath) legacy.initialPath = snapshot.initialPath;
return Object.keys(legacy).length > 0 ? legacy : undefined;
}
/**
+2
View File
@@ -22,6 +22,8 @@ export interface TabShellCallbacks {
type: string,
options?: { label?: string; data?: Record<string, unknown> },
) => void;
/** Connects the way clicking the host in the host list does. */
connectHost: (host: Host, type?: string) => void;
closeTab: (tabId: string) => void;
renameTab: (tabId: string, label: string) => void;
+14 -43
View File
@@ -29,12 +29,10 @@ import {
updateStatusCheckSettings,
getLogLevel,
updateLogLevel,
getOidcAutoProvision,
updateOidcAutoProvision,
getExternalAutoProvision,
updateExternalAutoProvision,
getSecondFactorAfterExternalLogin,
updateSecondFactorAfterExternalLogin,
getOidcSilentLoginDefault,
updateOidcSilentLoginDefault,
isElectron,
getUserRoles,
} from "@/main-axios";
@@ -128,25 +126,19 @@ export function AdminSettingsPanel({
const [logLevel, setLogLevel] = useState("info");
const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
const [analyticsLocked, setAnalyticsLocked] = useState(false);
const [oidcSilentLoginDefaultLocked, setOidcSilentLoginDefaultLocked] =
useState(false);
const [notificationPrivateEndpoints, setNotificationPrivateEndpoints] =
useState<string[]>([]);
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
// Terminal image storage state. localDir stays a draft: the API never
// returns the configured backend path, so it is only sent when changed.
const [brandingSettings, setBrandingSettings] =
useState<BrandingSettings | null>(null);
const [brandingSaving, setBrandingSaving] = useState(false);
const { applyBranding } = useBranding();
// External login state
const [oidcAutoProvision, setOidcAutoProvision] = useState(false);
const [externalAutoProvision, setExternalAutoProvision] = useState(false);
const [secondFactorAfterExternalLogin, setSecondFactorAfterExternalLogin] =
useState(false);
const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false);
// Create user dialog
const [createUserOpen, setCreateUserOpen] = useState(false);
@@ -258,7 +250,7 @@ export function AdminSettingsPanel({
id: user.userId,
username: user.username,
isAdmin: user.is_admin,
isOidc: user.is_oidc,
isOidc: user.is_external ?? user.is_oidc,
passwordHash: user.password_hash,
dataUnlocked: user.data_unlocked,
secondFactorEnabled: user.second_factor_enabled,
@@ -298,7 +290,6 @@ export function AdminSettingsPanel({
level,
oidcProv,
secondFactorExternal,
oidcSilent,
analytics,
notificationEndpoints,
branding,
@@ -309,9 +300,8 @@ export function AdminSettingsPanel({
getSessionTimeout(),
getStatusCheckSettings(),
getLogLevel(),
getOidcAutoProvision(),
getExternalAutoProvision(),
getSecondFactorAfterExternalLogin(),
getOidcSilentLoginDefault(),
getAnalyticsEnabled(),
getNotificationPrivateEndpoints(),
getBranding(),
@@ -326,13 +316,9 @@ export function AdminSettingsPanel({
);
}
if (oidcProv.status === "fulfilled")
setOidcAutoProvision(oidcProv.value.enabled);
setExternalAutoProvision(oidcProv.value.enabled);
if (secondFactorExternal.status === "fulfilled")
setSecondFactorAfterExternalLogin(secondFactorExternal.value.enabled);
if (oidcSilent.status === "fulfilled") {
setOidcSilentLoginDefault(oidcSilent.value.enabled);
setOidcSilentLoginDefaultLocked(oidcSilent.value.locked ?? false);
}
if (pwReset.status === "fulfilled") setAllowPasswordReset(pwReset.value);
if (timeout.status === "fulfilled")
setSessionTimeout(String(timeout.value.timeoutHours));
@@ -405,14 +391,14 @@ export function AdminSettingsPanel({
}
}
async function handleToggleOidcAutoProvision() {
const newVal = !oidcAutoProvision;
setOidcAutoProvision(newVal);
async function handleToggleExternalAutoProvision() {
const newVal = !externalAutoProvision;
setExternalAutoProvision(newVal);
try {
await updateOidcAutoProvision(newVal);
await updateExternalAutoProvision(newVal);
} catch {
setOidcAutoProvision(!newVal);
toast.error(t("admin.updateOidcAutoProvisionFailed"));
setExternalAutoProvision(!newVal);
toast.error(t("admin.updateExternalAutoProvisionFailed"));
}
}
@@ -427,18 +413,6 @@ export function AdminSettingsPanel({
}
}
async function handleToggleOidcSilentLoginDefault() {
if (oidcSilentLoginDefaultLocked) return;
const newVal = !oidcSilentLoginDefault;
setOidcSilentLoginDefault(newVal);
try {
await updateOidcSilentLoginDefault(newVal);
} catch {
setOidcSilentLoginDefault(!newVal);
toast.error(t("admin.updateOidcSilentLoginDefaultFailed"));
}
}
async function handleTogglePasswordReset() {
const newVal = !allowPasswordReset;
setAllowPasswordReset(newVal);
@@ -839,15 +813,12 @@ export function AdminSettingsPanel({
allowPasswordLogin={allowPasswordLogin}
passwordLoginForced={passwordLoginForced && !allowPasswordLogin}
handleTogglePasswordLogin={handleTogglePasswordLogin}
oidcAutoProvision={oidcAutoProvision}
handleToggleOidcAutoProvision={handleToggleOidcAutoProvision}
externalAutoProvision={externalAutoProvision}
handleToggleExternalAutoProvision={handleToggleExternalAutoProvision}
secondFactorAfterExternalLogin={secondFactorAfterExternalLogin}
handleToggleSecondFactorAfterExternalLogin={
handleToggleSecondFactorAfterExternalLogin
}
oidcSilentLoginDefault={oidcSilentLoginDefault}
oidcSilentLoginDefaultLocked={oidcSilentLoginDefaultLocked}
handleToggleOidcSilentLoginDefault={handleToggleOidcSilentLoginDefault}
allowPasswordReset={allowPasswordReset}
handleTogglePasswordReset={handleTogglePasswordReset}
sessionTimeout={sessionTimeout}
+6 -26
View File
@@ -22,13 +22,10 @@ type GeneralSettingsSectionProps = {
allowPasswordLogin: boolean;
passwordLoginForced?: boolean;
handleTogglePasswordLogin: () => void;
oidcAutoProvision: boolean;
handleToggleOidcAutoProvision: () => void;
externalAutoProvision: boolean;
handleToggleExternalAutoProvision: () => void;
secondFactorAfterExternalLogin: boolean;
handleToggleSecondFactorAfterExternalLogin: () => void;
oidcSilentLoginDefault: boolean;
oidcSilentLoginDefaultLocked: boolean;
handleToggleOidcSilentLoginDefault: () => void;
allowPasswordReset: boolean;
handleTogglePasswordReset: () => void;
sessionTimeout: string;
@@ -54,13 +51,10 @@ export function AdminGeneralSettingsSection({
allowPasswordLogin,
passwordLoginForced,
handleTogglePasswordLogin,
oidcAutoProvision,
handleToggleOidcAutoProvision,
externalAutoProvision,
handleToggleExternalAutoProvision,
secondFactorAfterExternalLogin,
handleToggleSecondFactorAfterExternalLogin,
oidcSilentLoginDefault,
oidcSilentLoginDefaultLocked,
handleToggleOidcSilentLoginDefault,
allowPasswordReset,
handleTogglePasswordReset,
sessionTimeout,
@@ -147,8 +141,8 @@ export function AdminGeneralSettingsSection({
description={t("admin.oidcAutoProvisionDesc")}
>
<AdminToggle
on={oidcAutoProvision}
onToggle={handleToggleOidcAutoProvision}
on={externalAutoProvision}
onToggle={handleToggleExternalAutoProvision}
/>
</SettingRow>
<SettingRow
@@ -160,20 +154,6 @@ export function AdminGeneralSettingsSection({
onToggle={handleToggleSecondFactorAfterExternalLogin}
/>
</SettingRow>
<SettingRow
label={t("admin.oidcSilentLoginDefault")}
description={
oidcSilentLoginDefaultLocked
? t("admin.oidcSilentLoginDefaultLockedDesc")
: t("admin.oidcSilentLoginDefaultDesc")
}
>
<AdminToggle
on={oidcSilentLoginDefault}
onToggle={handleToggleOidcSilentLoginDefault}
disabled={oidcSilentLoginDefaultLocked}
/>
</SettingRow>
<SettingRow
label={t("admin.allowPasswordReset")}
description={t("admin.allowPasswordResetDesc")}
+5 -5
View File
@@ -2,9 +2,9 @@ import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import {
assignRoleToUser,
linkOIDCToPasswordAccount,
linkExternalToPasswordAccount,
removeRoleFromUser,
unlinkOIDCFromPasswordAccount,
unlinkExternalFromPasswordAccount,
} from "@/main-axios";
import type { Role, UserRole } from "@/main-axios";
import { Button } from "@/components/button";
@@ -412,7 +412,7 @@ export function AdminUnlinkAccountDialog({
if (!unlinkAccountTarget) return;
setSubmitting(true);
try {
await unlinkOIDCFromPasswordAccount(unlinkAccountTarget.id);
await unlinkExternalFromPasswordAccount(unlinkAccountTarget.id);
toast.success(t("admin.unlinkAccountSuccess"));
onSuccess(unlinkAccountTarget.id);
onOpenChange(false);
@@ -498,7 +498,7 @@ export function AdminLinkAccountDialog({
setSubmitting(true);
try {
if (isOidcInitiator) {
await linkOIDCToPasswordAccount(linkAccountTarget.id, trimmed);
await linkExternalToPasswordAccount(linkAccountTarget.id, trimmed);
setUsers((prev) => prev.filter((u) => u.id !== linkAccountTarget.id));
} else {
const oidcUser = users.find(
@@ -508,7 +508,7 @@ export function AdminLinkAccountDialog({
toast.error(t("admin.linkAccountOidcNotFound"));
return;
}
await linkOIDCToPasswordAccount(
await linkExternalToPasswordAccount(
oidcUser.id,
linkAccountTarget.username,
);
+31 -120
View File
@@ -1,15 +1,8 @@
import { getTabType, isPersistentTabType } from "@/shell/tab-registry";
import { ComponentSlot } from "@/shell/ActionSlot";
import { useState, useEffect, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
ExternalLink,
Plug,
Search,
X,
Pencil,
Check,
SquareTerminal,
} from "lucide-react";
import { ExternalLink, Plug, Search, X, Pencil, Check } from "lucide-react";
import {
getActiveSessions,
deleteOpenTab,
@@ -20,7 +13,6 @@ import { tabIcon } from "@/shell/tabUtils";
import { getSessionTimeoutMinutes } from "@/api/open-tabs-api";
import type { Tab, TabType } from "@/types/ui-types";
import { Badge } from "@/components/badge";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
Tooltip,
@@ -64,11 +56,7 @@ function sessionsUnchanged(
a.hostName !== b.hostName ||
a.tabInstanceId !== b.tabInstanceId ||
a.isConnected !== b.isConnected ||
a.createdAt !== b.createdAt ||
a.isOwnSession !== b.isOwnSession ||
a.sharedByUsername !== b.sharedByUsername ||
a.permissionLevel !== b.permissionLevel ||
a.shareId !== b.shareId
a.createdAt !== b.createdAt
) {
return false;
}
@@ -296,7 +284,6 @@ export function ConnectionsPanel({
onForgetBackground,
onRenameTab,
onReorderTabs,
onJoinSharedSession,
}: {
tabs: Tab[];
activeTabId: string;
@@ -311,7 +298,6 @@ export function ConnectionsPanel({
onForgetBackground: (recordId: string) => void;
onRenameTab?: (tabId: string, newLabel: string) => void;
onReorderTabs?: (tabs: Tab[]) => void;
onJoinSharedSession?: (session: ActiveSessionInfo) => void;
}) {
const { t } = useTranslation();
const [now, setNow] = useState(Date.now());
@@ -360,22 +346,6 @@ export function ConnectionsPanel({
})
: backgroundTabs;
const joinedInstanceIds = new Set(
tabs
.map((t) => (t.joinSharedSessionId ? t.instanceId : null))
.filter(Boolean),
);
const sharedWithMe = activeSessions.filter(
(s) => s.isOwnSession === false && !joinedInstanceIds.has(s.sessionId),
);
const filteredSharedWithMe = q
? sharedWithMe.filter(
(s) =>
s.hostName.toLowerCase().includes(q) ||
(s.sharedByUsername ?? "").toLowerCase().includes(q),
)
: sharedWithMe;
// Duration labels only need minute-level freshness; 1s ticks re-render the whole panel.
usePageVisibleInterval(() => setNow(Date.now()), 15_000);
@@ -462,26 +432,36 @@ export function ConnectionsPanel({
activeSessions.map((s) => [s.tabInstanceId, s]),
);
const hasAnything =
openTabs.length > 0 || backgroundTabs.length > 0 || sharedWithMe.length > 0;
const hasAnything = openTabs.length > 0 || backgroundTabs.length > 0;
const hasResults =
filteredOpenTabs.length > 0 ||
filteredBackgroundTabs.length > 0 ||
filteredSharedWithMe.length > 0;
filteredOpenTabs.length > 0 || filteredBackgroundTabs.length > 0;
const pluginSections = (
<ComponentSlot
slotId="connections.sections"
props={{
search: q,
openTabData: tabs.map((tab) => tab.data ?? {}),
}}
/>
);
if (!hasAnything) {
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center py-16">
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
<Plug className="size-5 text-muted-foreground/30" />
</div>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-muted-foreground/60">
{t("connections.noConnections")}
</span>
<span className="text-xs text-muted-foreground/40">
{t("connections.noConnectionsDesc")}
</span>
<div className="flex flex-col flex-1">
{pluginSections}
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center py-16">
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
<Plug className="size-5 text-muted-foreground/30" />
</div>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-muted-foreground/60">
{t("connections.noConnections")}
</span>
<span className="text-xs text-muted-foreground/40">
{t("connections.noConnectionsDesc")}
</span>
</div>
</div>
</div>
);
@@ -629,77 +609,8 @@ export function ConnectionsPanel({
</div>
)}
{filteredSharedWithMe.length > 0 && (
<div
className={`flex flex-col ${filteredOpenTabs.length > 0 || filteredBackgroundTabs.length > 0 ? "mt-2" : ""}`}
>
<SectionHeader
label={t("connections.sectionSharedWithMe")}
count={filteredSharedWithMe.length}
/>
{filteredSharedWithMe.map((session) => (
<SharedWithMeRow
key={session.sessionId}
session={session}
onJoin={() => onJoinSharedSession?.(session)}
/>
))}
</div>
)}
</div>
);
}
function SharedWithMeRow({
session,
onJoin,
}: {
session: ActiveSessionInfo;
onJoin: () => void;
}) {
const { t } = useTranslation();
const isReadWrite = session.permissionLevel === "read-write";
return (
<div className="group flex items-center gap-2.5 px-3 py-2.5 border-b border-border/40 last:border-b-0">
<div className="shrink-0 flex items-center justify-center size-7 rounded bg-muted/60 text-muted-foreground">
<SquareTerminal className="size-3.5" />
</div>
<div className="flex flex-col flex-1 min-w-0 gap-0.5">
<div className="flex items-center gap-1.5 min-w-0">
<span
className={`shrink-0 size-1.5 rounded-full ${
session.isConnected ? "bg-green-500" : "bg-muted-foreground/30"
}`}
/>
<span className="text-xs font-semibold truncate flex-1 text-foreground">
{session.hostName}
</span>
<Badge
variant="outline"
className={`text-[9px] px-1 py-0 h-4 font-mono shrink-0 border-border/60 ${
isReadWrite ? "text-accent-brand" : "text-muted-foreground/60"
}`}
>
{isReadWrite
? t("sessionSharing.permissionLevel.readWrite")
: t("sessionSharing.permissionLevel.readOnly")}
</Badge>
</div>
<span className="text-[10px] text-muted-foreground/60 truncate pl-3">
{t("connections.sharedBy", {
username: session.sharedByUsername ?? "?",
})}
</span>
</div>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={onJoin}
>
{t("connections.join")}
</Button>
{/* Sections a plugin adds, such as sessions others shared with you. */}
{pluginSections}
</div>
);
}
-450
View File
@@ -1,450 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronRight,
Copy,
FolderOpen,
KeyRound,
Loader2,
Pencil,
Plus,
Trash2,
Upload,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { getCredentialDetails } from "@/main-axios";
import { copyToClipboard } from "@/lib/clipboard";
import { ComponentSlot } from "@/shell/ActionSlot";
import type { Host, Credential } from "@/types/ui-types";
type CredentialWithCertificate = Credential & { certPublicKey?: string };
type ConfirmDialog = {
message: string;
onConfirm: () => void;
};
function CredentialItem({
cred,
usedByHosts,
stripeIndex,
onDeploy,
onEdit,
onDelete,
}: {
cred: Credential;
usedByHosts: Host[];
stripeIndex: number;
onDeploy: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
const isKey = cred.type === "key";
const visibleHosts = usedByHosts.slice(0, 2);
const remainingHosts = Math.max(0, usedByHosts.length - visibleHosts.length);
const usedByTitle = usedByHosts
.map((host) => host.name || host.ip)
.join(", ");
return (
<div
className={`group relative flex items-stretch cursor-default select-none transition-colors hover:bg-muted/40 ${stripeIndex % 2 === 1 ? "bg-muted/20" : ""}`}
>
{/* Type stripe */}
<div className="w-[3px] shrink-0 bg-transparent" />
<div className="flex flex-col flex-1 min-w-0 px-2.5 pt-2 pb-1.5 gap-1">
{/* Name row */}
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[13px] font-medium truncate text-foreground leading-none">
{cred.name}
</span>
<span
className={`text-[9px] px-1 py-px font-bold border leading-none shrink-0 ${isKey ? "border-accent-brand/30 text-accent-brand" : "border-border/60 text-muted-foreground/60"}`}
>
{isKey ? "KEY" : "PWD"}
</span>
<ComponentSlot
slotId="credentials.badges"
props={{ credentialId: Number(cred.id) }}
/>
</div>
{/* Username row */}
{cred.username && (
<span className="text-[11px] text-muted-foreground/45 truncate leading-none pl-3">
{cred.username}
</span>
)}
{usedByHosts.length > 0 && (
<div
className="flex min-w-0 items-center gap-1 overflow-hidden pl-3"
title={usedByTitle}
aria-label={`Used by ${usedByTitle}`}
>
{visibleHosts.map((host) => {
const label = host.name || host.ip;
return (
<span
key={host.id}
className="max-w-[92px] truncate border border-accent-brand/25 bg-accent-brand/10 px-1.5 py-px text-[9px] font-semibold leading-none text-accent-brand/80"
title={label}
>
{label}
</span>
);
})}
{remainingHosts > 0 && (
<span
className="shrink-0 border border-border/50 bg-muted/30 px-1.5 py-px text-[9px] font-semibold leading-none text-muted-foreground/70"
title={usedByTitle}
>
+{remainingHosts}
</span>
)}
</div>
)}
{/* Tag pills */}
{cred.tags && cred.tags.length > 0 && (
<div className="flex items-center gap-1 min-w-0 overflow-hidden pl-3">
{cred.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="text-[9px] px-1 py-px border border-border/50 bg-muted/30 text-muted-foreground/60 lowercase shrink-0 leading-none"
>
{tag}
</span>
))}
{cred.tags.length > 4 && (
<span className="text-[9px] text-muted-foreground/40 shrink-0">
+{cred.tags.length - 4}
</span>
)}
</div>
)}
{/* Action tray — slides open on hover */}
<div className="overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 group-hover:max-h-[60px] group-hover:opacity-100">
<div className="flex items-center gap-1 pt-1.5 pl-2 pb-1 border-t border-border/40 mt-0.5">
{isKey && (
<>
<button
title="Deploy key to host"
onClick={onDeploy}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Upload className="size-3.5" />
</button>
<button
title="Copy deploy command"
onClick={async () => {
const pubKey = cred.publicKey;
if (!pubKey) {
toast.error(t("credentials.noPublicKeyAvailable"));
return;
}
const cmd = `mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo "${pubKey}" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`;
const ok = await copyToClipboard(cmd);
if (ok) toast.success(t("credentials.deployCommandCopied"));
else toast.error(t("common.copyFailed"));
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Copy className="size-3.5" />
</button>
</>
)}
<button
title="Edit credential"
onClick={onEdit}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Pencil className="size-3.5" />
</button>
<button
title="Delete credential"
onClick={onDelete}
className="flex items-center justify-center size-7 rounded text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="size-3.5" />
</button>
</div>
</div>
</div>
</div>
);
}
function CredentialFolderItem({
folder,
creds,
allHosts,
stripeOffset,
editingFolderName,
editingFolderValue,
onEditingFolderNameChange,
onEditingFolderValueChange,
onRenameFolder,
onDeploy,
onEdit,
onDelete,
}: {
folder: string;
creds: Credential[];
allHosts: Host[];
stripeOffset: number;
editingFolderName: string | null;
editingFolderValue: string;
onEditingFolderNameChange: (name: string | null) => void;
onEditingFolderValueChange: (value: string) => void;
onRenameFolder: (folder: string, newName: string) => Promise<void>;
onDeploy: (cred: Credential) => void;
onEdit: (cred: Credential) => void;
onDelete: (cred: Credential) => void;
}) {
const [open, setOpen] = useState(false);
return (
<div>
<button
onClick={() => setOpen((v) => !v)}
className={`group/folder flex items-center gap-2 w-full px-3 py-2 hover:bg-muted/50 transition-colors text-left cursor-pointer ${stripeOffset % 2 === 1 ? "bg-muted/20" : ""}`}
>
<ChevronRight
className={`size-3 shrink-0 text-muted-foreground/50 transition-transform ${open ? "rotate-90" : ""}`}
/>
<FolderOpen
className={`size-3.5 shrink-0 ${open ? "text-accent-brand" : "text-muted-foreground/60"}`}
/>
{editingFolderName === folder ? (
<>
<input
autoFocus
value={editingFolderValue}
onChange={(e) => onEditingFolderValueChange(e.target.value)}
onBlur={async () => {
const newName = editingFolderValue.trim();
onEditingFolderNameChange(null);
if (newName && newName !== folder) {
await onRenameFolder(folder, newName);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
if (e.key === "Escape") onEditingFolderNameChange(null);
}}
onClick={(e) => e.stopPropagation()}
className="text-[10px] font-semibold bg-background border border-accent-brand/60 px-1 outline-none text-foreground min-w-0 flex-1"
/>
<button
onClick={(e) => {
e.stopPropagation();
onEditingFolderNameChange(null);
}}
className="text-muted-foreground hover:text-foreground shrink-0"
>
<X className="size-3" />
</button>
</>
) : (
<>
<span className="text-[13px] font-semibold text-foreground/80 truncate flex-1">
{folder}
</span>
<span className="text-[10px] tabular-nums shrink-0 ml-1 text-muted-foreground/40">
{creds.length}
</span>
{folder !== "Uncategorized" && (
<span
className="opacity-0 group-hover/folder:opacity-100 transition-opacity ml-1 text-muted-foreground/50 hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onEditingFolderNameChange(folder);
onEditingFolderValueChange(folder);
}}
>
<Pencil className="size-2.5" />
</span>
)}
</>
)}
</button>
{open && (
<div className="border-l border-border/40 ml-[30px]">
{creds.map((cred, i) => {
const usedByHosts = allHosts.filter(
(h) => h.credentialId === cred.id,
);
return (
<CredentialItem
key={cred.id}
cred={cred}
usedByHosts={usedByHosts}
stripeIndex={stripeOffset + 1 + i}
onDeploy={() => onDeploy(cred)}
onEdit={() => onEdit(cred)}
onDelete={() => onDelete(cred)}
/>
);
})}
</div>
)}
</div>
);
}
export function HostCredentialList({
credentialFolders,
filteredCredentials,
credentialsLoading,
allHosts,
editingFolderName,
editingFolderValue,
onEditingFolderNameChange,
onEditingFolderValueChange,
onRenameFolder,
onDeployCredential,
onEditCredential,
onDeleteCredential,
onAddCredential,
onConfirmDialogChange,
}: {
credentialFolders: string[];
filteredCredentials: Credential[];
credentialsLoading: boolean;
allHosts: Host[];
editingFolderName: string | null;
editingFolderValue: string;
onEditingFolderNameChange: (name: string | null) => void;
onEditingFolderValueChange: (value: string) => void;
onRenameFolder: (folder: string, newName: string) => Promise<void>;
onDeployCredential: (cred: Credential) => void;
onEditCredential: (cred: Credential) => void;
onDeleteCredential: (cred: Credential) => Promise<void>;
onAddCredential: () => void;
onConfirmDialogChange: (dialog: ConfirmDialog) => void;
}) {
const { t } = useTranslation();
async function handleDelete(cred: Credential) {
onConfirmDialogChange({
message: t("hosts.deleteCredentialConfirm", { name: cred.name }),
onConfirm: async () => {
try {
await onDeleteCredential(cred);
toast.success(t("hosts.deletedCredential", { name: cred.name }));
} catch {
toast.error(t("hosts.failedToDeleteCredential2"));
}
},
});
}
async function handleEdit(cred: Credential) {
try {
const full = await getCredentialDetails(Number(cred.id));
onEditCredential({
...cred,
value: (
full as CredentialWithCertificate & {
hasKey?: boolean;
hasKeyPassword?: boolean;
}
).hasKey
? "existing_key"
: ((
full as CredentialWithCertificate & {
password?: string;
}
).password ?? ""),
password:
(full as CredentialWithCertificate & { password?: string })
.password ?? "",
passphrase: (
full as CredentialWithCertificate & {
hasKeyPassword?: boolean;
}
).hasKeyPassword
? "existing_key_password"
: "",
publicKey:
(full as CredentialWithCertificate).certPublicKey ?? cred.publicKey,
});
} catch {
onEditCredential(cred);
}
}
let globalStripe = 0;
return (
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col">
{credentialFolders.map((folder) => {
const creds = filteredCredentials.filter(
(c) => (c.folder || "Uncategorized") === folder,
);
if (creds.length === 0) return null;
const offset = globalStripe;
globalStripe += 1 + creds.length;
return (
<CredentialFolderItem
key={folder}
folder={folder}
creds={creds}
allHosts={allHosts}
stripeOffset={offset}
editingFolderName={editingFolderName}
editingFolderValue={editingFolderValue}
onEditingFolderNameChange={onEditingFolderNameChange}
onEditingFolderValueChange={onEditingFolderValueChange}
onRenameFolder={onRenameFolder}
onDeploy={onDeployCredential}
onEdit={handleEdit}
onDelete={handleDelete}
/>
);
})}
{credentialsLoading && (
<div className="flex flex-col px-2 py-2 space-y-1.5">
{[60, 45, 55, 40].map((w, i) => (
<div key={i} className="flex items-center gap-2 px-3 py-2">
<div className="size-3 rounded-sm bg-muted/50 animate-pulse shrink-0" />
<div
className="h-3 rounded bg-muted/50 animate-pulse"
style={{ width: `${w * 2}px` }}
/>
</div>
))}
<div className="flex items-center justify-center gap-2 pt-2 text-muted-foreground/40">
<Loader2 className="size-3.5 animate-spin" />
<span className="text-xs">{t("hosts.loadingCredentials")}</span>
</div>
</div>
)}
{!credentialsLoading && filteredCredentials.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center px-4">
<KeyRound className="size-8 text-muted-foreground/20 mb-2" />
<span className="text-sm font-semibold text-muted-foreground/60">
{t("hosts.noCredentialsFound")}
</span>
<Button
variant="outline"
size="sm"
className="mt-3 h-7 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
onClick={onAddCredential}
>
<Plus className="size-3 mr-1" />
{t("hosts.addCredentialBtn2")}
</Button>
</div>
)}
</div>
</div>
);
}
+5 -9
View File
@@ -21,7 +21,6 @@ import {
import { getHostDefaults } from "@/api/settings-api";
import type { Host } from "@/types/ui-types";
import type { SSHHost } from "@/types";
import { useTabsSafe } from "@/shell/TabContext";
import { updatePluginHostSettings } from "@/api/plugins-api";
import {
applyHostDraft,
@@ -93,7 +92,6 @@ export function HostEditor({
}) {
const { t } = useTranslation();
const hostSettingPlugins = usePluginHostSections();
const { setPreviewTerminalTheme } = useTabsSafe();
const [form, setForm] = useState(() =>
applyHostDraft(createHostEditorForm(host), host ? undefined : draft),
);
@@ -124,7 +122,7 @@ export function HostEditor({
useState<CredentialOption | null>(null);
useEffect(() => {
getUserInfo()
.then((info) => setIsOidcUser(info.is_oidc))
.then((info) => setIsOidcUser(info.is_external ?? info.is_oidc))
.catch(() => {});
}, []);
@@ -196,7 +194,6 @@ export function HostEditor({
);
toast.success(host ? t("hosts.hostUpdated") : t("hosts.hostCreated"));
setPreviewTerminalTheme(null);
onSave(saved);
} catch {
toast.error(t("hosts.failedToSave"));
@@ -1104,12 +1101,11 @@ export function HostEditor({
<Button
variant="ghost"
onClick={() => {
setPreviewTerminalTheme(null);
onBack();
}}
disabled={saving}
>
{t("hosts.guac.cancelBtn")}
{t("hosts.editorCancel")}
</Button>
{!readOnly && (
<Button
@@ -1119,10 +1115,10 @@ export function HostEditor({
disabled={saving}
>
{saving
? t("hosts.guac.savingBtn")
? t("hosts.editorSaving")
: host
? t("hosts.guac.updateHostBtn")
: t("hosts.guac.addHostBtn")}
? t("hosts.editorUpdate")
: t("hosts.editorAdd")}
</Button>
)}
</div>
+15 -3
View File
@@ -1,4 +1,5 @@
import { getErrorMessage } from "../lib/error-message.js";
import { usePluginStore } from "@/plugin-host/plugin-store";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Download, Search } from "lucide-react";
@@ -17,6 +18,7 @@ import { exportAllSSHHosts, type SSHHostWithStatus } from "@/main-axios";
import { isFolder } from "@/sidebar/SidebarTree";
import {
buildExportPayload,
pluginSecretKeys,
hostKey,
maskSecrets,
type ExportPayload,
@@ -135,6 +137,15 @@ export function HostExportDialog({
};
}, [open, withCredentials, t]);
const { records: pluginRecords } = usePluginStore();
const pluginSecrets = useMemo(
() =>
pluginSecretKeys(
[...pluginRecords.values()].map((record) => record.summary),
),
[pluginRecords],
);
const payload = useMemo(() => {
if (!raw) return null;
return buildExportPayload(
@@ -142,12 +153,13 @@ export function HostExportDialog({
scope === "all" ? null : selectedKeys,
groups,
withCredentials,
pluginSecrets,
);
}, [raw, scope, selectedKeys, groups, withCredentials]);
}, [raw, scope, selectedKeys, groups, withCredentials, pluginSecrets]);
const preview = useMemo(() => {
if (!payload) return "";
const masked = maskSecrets(payload);
const masked = maskSecrets(payload, pluginSecrets);
if (masked.hosts.length <= PREVIEW_HOST_LIMIT) {
return JSON.stringify(masked, null, 2);
}
@@ -163,7 +175,7 @@ export function HostExportDialog({
count: payload.hosts.length - PREVIEW_HOST_LIMIT,
})
);
}, [payload, t]);
}, [payload, pluginSecrets, t]);
function toggleGroup(group: FieldGroup) {
setGroups((prev) => {
+4 -4
View File
@@ -291,10 +291,10 @@ export function HostsPanel({
? t("hosts.filterOnline")
: t("hosts.filterOffline");
if (key === "protocol") return group.toUpperCase();
if (key === "auth")
return t(
`hosts.filterAuth${group.charAt(0).toUpperCase() + group.slice(1)}`,
);
if (key === "auth") {
const labelKey = sshAuthProviders.find(group)?.labelKey;
return labelKey ? t(labelKey, { defaultValue: group }) : group;
}
return group;
}
+2 -4
View File
@@ -647,7 +647,7 @@ export function UserProfilePanel({
getUserInfo()
.then((info) => {
setUserId(info.userId);
setIsOidc(info.is_oidc ?? false);
setIsOidc(info.is_external ?? info.is_oidc ?? false);
setIsDualAuth(info.is_dual_auth ?? false);
const linked = info.linked ?? null;
setLinkedAccount(linked);
@@ -661,7 +661,7 @@ export function UserProfilePanel({
);
if (info.is_dual_auth) {
setAuthMethod(t("newUi.sidebar.userProfile.authMethodDual"));
} else if (info.is_oidc) {
} else if (info.is_external ?? info.is_oidc) {
setAuthMethod(t("newUi.sidebar.userProfile.authMethodOidc"));
} else {
setAuthMethod(t("newUi.sidebar.userProfile.authMethodLocal"));
@@ -764,8 +764,6 @@ export function UserProfilePanel({
// and back silently reset them.
"dashboardTab.slots",
"dashboardTab.mainWidthPct",
"termix-terminal-toolbar-density",
"fileManagerViewMode",
];
const snap: Record<string, string | null> = { __theme: theme };
for (const key of SNAPSHOT_KEYS) snap[key] = localStorage.getItem(key);
@@ -43,10 +43,8 @@ const CREDENTIAL_ITEM_DENSITY_TOKENS = {
export function CredentialItem({
cred,
usedByCount = 0,
query = "",
stripeIndex = 0,
isMenuOpen = false,
onMenuOpenChange,
isTrayOpen = false,
onTrayOpenChange,
onDragStart,
+50 -39
View File
@@ -65,29 +65,42 @@ const CREDENTIAL_KEYS = [
const TUPLE_KEYS = ["name", "ip", "port", "username", "connectionType"];
const NESTED_SECRETS: { container: string; field: string }[] = [
{ container: "guacamoleConfig", field: "gateway-password" },
];
/**
* Keys inside a plugin's JSON host settings that hold secrets, per plugin and
* field, from each manifest's `secretKeys`.
*/
export type PluginSecretKeys = Record<string, Record<string, string[]>>;
/** Reads the secret keys the plugins declare in their host settings. */
export function pluginSecretKeys(
summaries: Array<{
id: string;
contributes?: {
settings?: {
host?: { fields?: Array<{ key: string; secretKeys?: string[] }> };
};
} | null;
}>,
): PluginSecretKeys {
const result: PluginSecretKeys = {};
for (const summary of summaries) {
for (const field of summary.contributes?.settings?.host?.fields ?? []) {
if (!field.secretKeys?.length) continue;
(result[summary.id] ??= {})[field.key] = field.secretKeys;
}
}
return result;
}
const NESTED_SECRET_ARRAYS: { container: string; field: string }[] = [
{ container: "socks5ProxyChain", field: "password" },
];
function nestedSecret(
host: Record<string, unknown>,
container: string,
field: string,
): Record<string, unknown> | null {
const blob = host[container];
if (!blob || typeof blob !== "object") return null;
const record = blob as Record<string, unknown>;
return field in record ? record : null;
}
/** Plugin host settings can carry the same nested secrets as the host. */
function mapPluginSettings(
/** Rewrites each declared secret inside a plugin's JSON host settings. */
function mapPluginSecrets(
pluginSettings: unknown,
apply: (values: Record<string, unknown>) => void,
secrets: PluginSecretKeys,
replace: (value: unknown) => unknown,
): unknown {
if (!pluginSettings || typeof pluginSettings !== "object") {
return pluginSettings;
@@ -97,7 +110,15 @@ function mapPluginSettings(
pluginSettings as Record<string, unknown>,
)) {
const copy = { ...(values as Record<string, unknown>) };
apply(copy);
for (const [field, keys] of Object.entries(secrets[pluginId] ?? {})) {
const blob = copy[field];
if (!blob || typeof blob !== "object") continue;
const record = { ...(blob as Record<string, unknown>) };
for (const key of keys) {
if (key in record) record[key] = replace(record[key]);
}
copy[field] = record;
}
perPlugin[pluginId] = copy;
}
return perPlugin;
@@ -112,6 +133,7 @@ export function buildExportPayload(
selected: Set<string> | null,
groups: Set<FieldGroup>,
withCredentials: boolean,
pluginSecrets: PluginSecretKeys = {},
): ExportPayload {
const allowed = new Set<string>([
"exportId",
@@ -130,16 +152,10 @@ export function buildExportPayload(
if (allowed.has(key)) shaped[key] = value;
}
if (!withCredentials) {
const clearNested = (target: Record<string, unknown>) => {
for (const { container, field } of NESTED_SECRETS) {
const record = nestedSecret(target, container, field);
if (record) target[container] = { ...record, [field]: null };
}
};
clearNested(shaped);
shaped.pluginSettings = mapPluginSettings(
shaped.pluginSettings = mapPluginSecrets(
shaped.pluginSettings,
clearNested,
pluginSecrets,
() => null,
);
for (const { container, field } of NESTED_SECRET_ARRAYS) {
const arr = shaped[container];
@@ -169,7 +185,10 @@ export function buildExportPayload(
return result;
}
export function maskSecrets(payload: ExportPayload): ExportPayload {
export function maskSecrets(
payload: ExportPayload,
pluginSecrets: PluginSecretKeys = {},
): ExportPayload {
return {
...payload,
hosts: payload.hosts.map((host) => {
@@ -183,18 +202,10 @@ export function maskSecrets(payload: ExportPayload): ExportPayload {
masked[key] = "<included>";
}
}
const maskNested = (target: Record<string, unknown>) => {
for (const { container, field } of NESTED_SECRETS) {
const record = nestedSecret(target, container, field);
if (record && record[field]) {
target[container] = { ...record, [field]: "<included>" };
}
}
};
maskNested(masked);
masked.pluginSettings = mapPluginSettings(
masked.pluginSettings = mapPluginSecrets(
masked.pluginSettings,
maskNested,
pluginSecrets,
(value) => (value ? "<included>" : value),
);
for (const { container, field } of NESTED_SECRET_ARRAYS) {
const arr = masked[container];
-48
View File
@@ -1,48 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { streamServerSentEvents } from "@/api/sse-stream";
describe("streamServerSentEvents", () => {
it("sends the supplied authentication options and parses split events", async () => {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("event: stat"));
controller.enqueue(
new TextEncoder().encode(
'uses\ndata: {"tunnel":{"status":"connected"}}\n\n',
),
);
controller.close();
},
});
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response(stream, { status: 200 }));
const onEvent = vi.fn();
const headers = new Headers({ Authorization: "Bearer token" });
await streamServerSentEvents(
"http://localhost/stream",
{ credentials: "include", headers },
onEvent,
);
expect(fetchMock).toHaveBeenCalledWith("http://localhost/stream", {
credentials: "include",
headers,
});
expect(onEvent).toHaveBeenCalledWith({
event: "statuses",
data: '{"tunnel":{"status":"connected"}}',
});
});
it("rejects an unauthorized stream", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(null, { status: 401 }),
);
await expect(
streamServerSentEvents("http://localhost/stream", {}, vi.fn()),
).rejects.toThrow("status 401");
});
});
@@ -29,8 +29,7 @@ const shell = {
openSingletonTab: () => {},
closeTab: () => {},
renameTab: () => {},
openFileInEditor: () => {},
openFileManager: () => {},
connectHost: () => {},
openRailView: () => {},
closeRailView: () => {},
};
@@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { PluginSettingsField } from "@/api/plugins-api";
import { SettingsFieldRow } from "@/settings/SettingsFields";
import { isFieldActive } from "@/settings/settings-fields-util";
import { isFieldShown } from "@/settings/settings-fields-util";
import {
registerSettingsComponent,
resetSettingsComponents,
@@ -272,7 +272,7 @@ describe("errors", () => {
});
});
describe("isFieldActive", () => {
describe("isFieldShown", () => {
const gated: PluginSettingsField = {
key: "socketPath",
type: "string",
@@ -281,16 +281,16 @@ describe("isFieldActive", () => {
};
it("is inactive while the named boolean is off", () => {
expect(isFieldActive(gated, { enableDocker: false })).toBe(false);
expect(isFieldActive(gated, {})).toBe(false);
expect(isFieldShown(gated, { enableDocker: false })).toBe(false);
expect(isFieldShown(gated, {})).toBe(false);
});
it("is active once the named boolean is on", () => {
expect(isFieldActive(gated, { enableDocker: true })).toBe(true);
expect(isFieldShown(gated, { enableDocker: true })).toBe(true);
});
it("is always active without a requires", () => {
expect(isFieldActive({ key: "a", type: "string", labelKey: "k" }, {})).toBe(
expect(isFieldShown({ key: "a", type: "string", labelKey: "k" }, {})).toBe(
true,
);
});
+38 -118
View File
@@ -1,11 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
render,
screen,
waitFor,
cleanup,
fireEvent,
} from "@testing-library/react";
import { render, screen, waitFor, cleanup } from "@testing-library/react";
import type { ActiveSessionInfo } from "@/api/open-tabs-api";
const mainAxios = vi.hoisted(() => ({
@@ -23,136 +17,62 @@ vi.mock("react-i18next", () => ({
}));
import { ConnectionsPanel } from "../../sidebar/ConnectionsPanel";
function sharedSession(
overrides: Partial<ActiveSessionInfo> = {},
): ActiveSessionInfo {
return {
sessionId: "sess-shared-1",
hostId: 5,
hostName: "prod-db",
tabInstanceId: null,
isConnected: true,
createdAt: Date.now(),
isOwnSession: false,
sharedByUsername: "alice",
permissionLevel: "read-only",
shareId: "share-1",
...overrides,
};
}
import {
registerSlotContribution,
resetActionRegistry,
} from "../../shell/action-registry";
beforeEach(() => {
mainAxios.getActiveSessions.mockReset();
mainAxios.getActiveSessions.mockResolvedValue([]);
mainAxios.deleteOpenTab.mockReset();
resetActionRegistry();
});
afterEach(() => {
cleanup();
});
describe("ConnectionsPanel - shared with me", () => {
it("renders a shared-with-me row for sessions the current user does not own", async () => {
mainAxios.getActiveSessions.mockResolvedValue([sharedSession()]);
function renderPanel() {
return render(
<ConnectionsPanel
tabs={[]}
activeTabId=""
allHosts={[]}
backgroundTabRecords={[]}
onSwitchToTab={() => {}}
onCloseTab={() => {}}
onReopenTab={() => {}}
onForgetBackground={() => {}}
/>,
);
}
render(
<ConnectionsPanel
tabs={[]}
activeTabId=""
allHosts={[]}
backgroundTabRecords={[]}
onSwitchToTab={() => {}}
onCloseTab={() => {}}
onReopenTab={() => {}}
onForgetBackground={() => {}}
/>,
);
describe("ConnectionsPanel - plugin sections", () => {
it("renders what plugins add to connections.sections, even with no tabs", async () => {
const Section = vi.fn(({ search }: { search?: string }) => (
<div>plugin section {JSON.stringify(search)}</div>
));
registerSlotContribution("connections.sections", {
actionId: "demo.section",
titleKey: "demo.section",
kind: "component",
component: Section as never,
});
renderPanel();
await waitFor(() => {
expect(screen.getByText("connections.sectionSharedWithMe")).toBeTruthy();
expect(screen.getByText('plugin section ""')).toBeTruthy();
});
expect(screen.getByText("prod-db")).toBeTruthy();
expect(
screen.getByText('connections.sharedBy:{"username":"alice"}'),
).toBeTruthy();
expect(screen.getByText("connections.noConnections")).toBeTruthy();
});
it("does not show own sessions in the shared-with-me section", async () => {
mainAxios.getActiveSessions.mockResolvedValue([
sharedSession({
isOwnSession: true,
sharedByUsername: null,
shareId: null,
}),
]);
render(
<ConnectionsPanel
tabs={[]}
activeTabId=""
allHosts={[]}
backgroundTabRecords={[]}
onSwitchToTab={() => {}}
onCloseTab={() => {}}
onReopenTab={() => {}}
onForgetBackground={() => {}}
/>,
);
it("renders only the empty state without plugin sections", async () => {
renderPanel();
await waitFor(() => {
expect(mainAxios.getActiveSessions).toHaveBeenCalled();
});
expect(screen.queryByText("connections.sectionSharedWithMe")).toBeNull();
});
it("dispatches onJoinSharedSession with the session when Join is clicked", async () => {
const session = sharedSession();
mainAxios.getActiveSessions.mockResolvedValue([session]);
const onJoinSharedSession = vi.fn();
render(
<ConnectionsPanel
tabs={[]}
activeTabId=""
allHosts={[]}
backgroundTabRecords={[]}
onSwitchToTab={() => {}}
onCloseTab={() => {}}
onReopenTab={() => {}}
onForgetBackground={() => {}}
onJoinSharedSession={onJoinSharedSession}
/>,
);
const joinButton = await screen.findByText("connections.join");
fireEvent.click(joinButton);
expect(onJoinSharedSession).toHaveBeenCalledWith(session);
});
it("shows a read-write badge for read-write shared sessions", async () => {
mainAxios.getActiveSessions.mockResolvedValue([
sharedSession({ permissionLevel: "read-write" }),
]);
render(
<ConnectionsPanel
tabs={[]}
activeTabId=""
allHosts={[]}
backgroundTabRecords={[]}
onSwitchToTab={() => {}}
onCloseTab={() => {}}
onReopenTab={() => {}}
onForgetBackground={() => {}}
/>,
);
await waitFor(() => {
expect(
screen.getByText("sessionSharing.permissionLevel.readWrite"),
).toBeTruthy();
});
expect(screen.getByText("connections.noConnections")).toBeTruthy();
});
});
@@ -1,129 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { HostCredentialList } from "@/sidebar/HostCredentialList";
import type { Credential, Host } from "@/types/ui-types";
vi.mock("@/main-axios", () => ({
getCredentialDetails: vi.fn(),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
function makeCredential(overrides: Partial<Credential> = {}): Credential {
return {
id: "1",
name: "Deploy Key",
username: "ubuntu",
type: "key",
folder: "Shared",
tags: [],
...overrides,
};
}
function makeHost(overrides: Partial<Host>): Host {
return {
id: "host-1",
name: "prod-api",
username: "ubuntu",
ip: "10.0.0.1",
port: 22,
folder: "",
online: false,
cpu: null,
ram: null,
lastAccess: "",
authType: "credential",
enableSsh: true,
sshPort: 22,
...overrides,
};
}
async function renderOpenedList({
credentials = [makeCredential()],
hosts = [],
}: {
credentials?: Credential[];
hosts?: Host[];
} = {}) {
render(
<HostCredentialList
credentialFolders={["Shared"]}
filteredCredentials={credentials}
credentialsLoading={false}
allHosts={hosts}
editingFolderName={null}
editingFolderValue=""
onEditingFolderNameChange={vi.fn()}
onEditingFolderValueChange={vi.fn()}
onRenameFolder={vi.fn()}
onDeployCredential={vi.fn()}
onEditCredential={vi.fn()}
onDeleteCredential={vi.fn()}
onAddCredential={vi.fn()}
onConfirmDialogChange={vi.fn()}
/>,
);
await userEvent.click(screen.getByText("Shared"));
}
describe("HostCredentialList credential usage", () => {
it("shows the SSH host using a credential", async () => {
await renderOpenedList({
hosts: [makeHost({ id: "host-1", name: "prod-api", credentialId: "1" })],
});
expect(screen.getByText("prod-api")).toBeTruthy();
});
it("shows two host names and a compact remaining count", async () => {
await renderOpenedList({
hosts: [
makeHost({ id: "host-1", name: "prod-api", credentialId: "1" }),
makeHost({ id: "host-2", name: "jump-box", credentialId: "1" }),
makeHost({ id: "host-3", name: "worker-01", credentialId: "1" }),
],
});
expect(screen.getByText("prod-api")).toBeTruthy();
expect(screen.getByText("jump-box")).toBeTruthy();
expect(screen.getByText("+1")).toBeTruthy();
expect(screen.queryByText("worker-01")).toBeNull();
});
it("does not show host chips when no SSH host uses the credential", async () => {
await renderOpenedList();
expect(screen.queryByLabelText(/^Used by /)).toBeNull();
});
it("does not count non-SSH protocol credential ids", async () => {
await renderOpenedList({
hosts: [
makeHost({
id: "host-1",
name: "rdp-only",
authType: "password",
credentialId: undefined,
protocolAuth: {
rdp: { authType: "credential", credentialId: 1 },
},
enableSsh: false,
}),
],
});
expect(screen.queryByText("rdp-only")).toBeNull();
expect(screen.queryByLabelText(/^Used by /)).toBeNull();
});
});
@@ -14,6 +14,37 @@ const mainAxios = vi.hoisted(() => ({
vi.mock("@/main-axios", () => mainAxios);
// The remote-desktop plugin declares its gateway password as a secret key.
vi.mock("@/plugin-host/plugin-store", () => ({
usePluginStore: () => ({
loaded: true,
settled: true,
records: new Map([
[
"remote-desktop",
{
frontend: "active",
summary: {
id: "remote-desktop",
contributes: {
settings: {
host: {
fields: [
{
key: "guacamoleConfig",
secretKeys: ["gateway-password"],
},
],
},
},
},
},
},
],
]),
}),
}));
const i18n = vi.hoisted(() => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts ? `${key}:${JSON.stringify(opts)}` : key,
@@ -6,8 +6,14 @@ import {
SECRET_KEYS,
type ExportPayload,
type FieldGroup,
pluginSecretKeys,
} from "../../sidebar/host-export-payload";
// What the remote-desktop manifest declares for its guacamoleConfig field.
const RDP_SECRETS = {
"remote-desktop": { guacamoleConfig: ["gateway-password"] },
};
const ALL_GROUPS = new Set<FieldGroup>([
"connection",
"notes",
@@ -208,7 +214,13 @@ describe("buildExportPayload", () => {
});
it("nulls the guacamole gateway password when credentials are excluded", () => {
const out = buildExportPayload(rdpRaw(), null, ALL_GROUPS, false);
const out = buildExportPayload(
rdpRaw(),
null,
ALL_GROUPS,
false,
RDP_SECRETS,
);
const config = gatewayConfig(out.hosts[0]);
expect(config["gateway-password"]).toBeNull();
expect(config["gateway-hostname"]).toBe("gw.example.com");
@@ -222,7 +234,7 @@ describe("buildExportPayload", () => {
it("does not mutate the source payload when nulling nested secrets", () => {
const raw = rdpRaw();
buildExportPayload(raw, null, ALL_GROUPS, false);
buildExportPayload(raw, null, ALL_GROUPS, false, RDP_SECRETS);
const config = gatewayConfig(raw.hosts[0]);
expect(config["gateway-password"]).toBe("gw-secret");
});
@@ -270,7 +282,7 @@ describe("maskSecrets", () => {
});
it("masks the guacamole gateway password", () => {
const out = maskSecrets(rdpRaw());
const out = maskSecrets(rdpRaw(), RDP_SECRETS);
const config = gatewayConfig(out.hosts[0]);
expect(config["gateway-password"]).toBe("<included>");
expect(config["gateway-hostname"]).toBe("gw.example.com");
@@ -286,3 +298,26 @@ describe("maskSecrets", () => {
expect(JSON.stringify(out)).not.toContain("proxy-secret-2");
});
});
describe("pluginSecretKeys", () => {
it("reads secretKeys off each plugin's host fields", () => {
expect(
pluginSecretKeys([
{
id: "remote-desktop",
contributes: {
settings: {
host: {
fields: [
{ key: "guacamoleConfig", secretKeys: ["gateway-password"] },
{ key: "enableRdp" },
],
},
},
},
},
{ id: "docker", contributes: null },
]),
).toEqual(RDP_SECRETS);
});
});
+23 -2
View File
@@ -150,8 +150,6 @@ describe("PRESETS.balanced", () => {
rowActions: "full",
});
expect(PRESETS.balanced.rail.hiddenTabs).toEqual([]);
expect(PRESETS.balanced.terminal.toolbarDensity).toBe("labeled");
expect(PRESETS.balanced.fileManager.viewMode).toBe("grid");
expect(PRESETS.balanced.hostEditor.mode).toBe("full");
});
@@ -220,3 +218,26 @@ describe("plugin areas", () => {
expect(overrides).toEqual({ "plugin:docker": { ok: "card" } });
});
});
describe("areas that moved into plugins", () => {
it("maps version 2 terminal and file manager overrides to their plugins", () => {
expect(
sanitizeUiOverrides(
{
terminal: { toolbarDensity: "expanded" },
fileManager: { viewMode: "list" },
},
2,
),
).toEqual({
"plugin:ssh-terminal": { toolbarDensity: "expanded" },
"plugin:file-manager": { viewMode: "list" },
});
});
it("drops the old names from a current payload", () => {
expect(
sanitizeUiOverrides({ terminal: { toolbarDensity: "icon" } }),
).toEqual({});
});
});
+84
View File
@@ -0,0 +1,84 @@
/*
* The design tokens plugins build against: custom variants, the Tailwind
* theme and shared utilities. Core imports it, and the SDK ships a copy that
* `termix-plugin build` compiles a plugin's own classes against, so a plugin
* class means the same thing wherever it is compiled. Values only; anything
* that renders on its own belongs in index.css.
*/
@custom-variant dark (&:is(.dark *));
@custom-variant dracula (&:is(.dracula *));
@custom-variant catppuccin (&:is(.catppuccin *));
@custom-variant nord (&:is(.nord *));
@custom-variant solarized (&:is(.solarized *));
@custom-variant tokyo-night (&:is(.tokyo-night *));
@custom-variant one-dark (&:is(.one-dark *));
@custom-variant gruvbox (&:is(.gruvbox *));
@theme inline {
--font-heading: var(--font-mono);
--font-mono: var(--font-ui, "JetBrains Mono Variable", monospace);
--color-accent-brand: var(--accent-brand);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--color-surface: var(--surface);
--color-surface-dim: var(--surface-dim);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
@utility scrollbar-none {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
@utility thin-scrollbar {
scrollbar-width: thin;
scrollbar-color: oklch(1 0 0 / 15%) transparent;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: oklch(1 0 0 / 15%);
border-radius: 2px;
}
}