mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: harden plugin api for separate repos, signed updates and generic external login
This commit is contained in:
1 parent
fb9c168b6b
commit
80944301dd
310 files changed
+6577
-4927
No files matched your search
+14
-75
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 : [];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function updateAnalyticsEnabled(
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TERMINAL IMAGE STORAGE SETTINGS
|
||||
// BRANDING SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
export interface BrandingSettings {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
+1
-1
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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";
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user