mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45: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
@@ -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];
|
||||
|
||||
Reference in new issue
Block a user