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

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

No files matched your search

+14 -43
View File
@@ -29,12 +29,10 @@ import {
updateStatusCheckSettings,
getLogLevel,
updateLogLevel,
getOidcAutoProvision,
updateOidcAutoProvision,
getExternalAutoProvision,
updateExternalAutoProvision,
getSecondFactorAfterExternalLogin,
updateSecondFactorAfterExternalLogin,
getOidcSilentLoginDefault,
updateOidcSilentLoginDefault,
isElectron,
getUserRoles,
} from "@/main-axios";
@@ -128,25 +126,19 @@ export function AdminSettingsPanel({
const [logLevel, setLogLevel] = useState("info");
const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
const [analyticsLocked, setAnalyticsLocked] = useState(false);
const [oidcSilentLoginDefaultLocked, setOidcSilentLoginDefaultLocked] =
useState(false);
const [notificationPrivateEndpoints, setNotificationPrivateEndpoints] =
useState<string[]>([]);
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
// Terminal image storage state. localDir stays a draft: the API never
// returns the configured backend path, so it is only sent when changed.
const [brandingSettings, setBrandingSettings] =
useState<BrandingSettings | null>(null);
const [brandingSaving, setBrandingSaving] = useState(false);
const { applyBranding } = useBranding();
// External login state
const [oidcAutoProvision, setOidcAutoProvision] = useState(false);
const [externalAutoProvision, setExternalAutoProvision] = useState(false);
const [secondFactorAfterExternalLogin, setSecondFactorAfterExternalLogin] =
useState(false);
const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false);
// Create user dialog
const [createUserOpen, setCreateUserOpen] = useState(false);
@@ -258,7 +250,7 @@ export function AdminSettingsPanel({
id: user.userId,
username: user.username,
isAdmin: user.is_admin,
isOidc: user.is_oidc,
isOidc: user.is_external ?? user.is_oidc,
passwordHash: user.password_hash,
dataUnlocked: user.data_unlocked,
secondFactorEnabled: user.second_factor_enabled,
@@ -298,7 +290,6 @@ export function AdminSettingsPanel({
level,
oidcProv,
secondFactorExternal,
oidcSilent,
analytics,
notificationEndpoints,
branding,
@@ -309,9 +300,8 @@ export function AdminSettingsPanel({
getSessionTimeout(),
getStatusCheckSettings(),
getLogLevel(),
getOidcAutoProvision(),
getExternalAutoProvision(),
getSecondFactorAfterExternalLogin(),
getOidcSilentLoginDefault(),
getAnalyticsEnabled(),
getNotificationPrivateEndpoints(),
getBranding(),
@@ -326,13 +316,9 @@ export function AdminSettingsPanel({
);
}
if (oidcProv.status === "fulfilled")
setOidcAutoProvision(oidcProv.value.enabled);
setExternalAutoProvision(oidcProv.value.enabled);
if (secondFactorExternal.status === "fulfilled")
setSecondFactorAfterExternalLogin(secondFactorExternal.value.enabled);
if (oidcSilent.status === "fulfilled") {
setOidcSilentLoginDefault(oidcSilent.value.enabled);
setOidcSilentLoginDefaultLocked(oidcSilent.value.locked ?? false);
}
if (pwReset.status === "fulfilled") setAllowPasswordReset(pwReset.value);
if (timeout.status === "fulfilled")
setSessionTimeout(String(timeout.value.timeoutHours));
@@ -405,14 +391,14 @@ export function AdminSettingsPanel({
}
}
async function handleToggleOidcAutoProvision() {
const newVal = !oidcAutoProvision;
setOidcAutoProvision(newVal);
async function handleToggleExternalAutoProvision() {
const newVal = !externalAutoProvision;
setExternalAutoProvision(newVal);
try {
await updateOidcAutoProvision(newVal);
await updateExternalAutoProvision(newVal);
} catch {
setOidcAutoProvision(!newVal);
toast.error(t("admin.updateOidcAutoProvisionFailed"));
setExternalAutoProvision(!newVal);
toast.error(t("admin.updateExternalAutoProvisionFailed"));
}
}
@@ -427,18 +413,6 @@ export function AdminSettingsPanel({
}
}
async function handleToggleOidcSilentLoginDefault() {
if (oidcSilentLoginDefaultLocked) return;
const newVal = !oidcSilentLoginDefault;
setOidcSilentLoginDefault(newVal);
try {
await updateOidcSilentLoginDefault(newVal);
} catch {
setOidcSilentLoginDefault(!newVal);
toast.error(t("admin.updateOidcSilentLoginDefaultFailed"));
}
}
async function handleTogglePasswordReset() {
const newVal = !allowPasswordReset;
setAllowPasswordReset(newVal);
@@ -839,15 +813,12 @@ export function AdminSettingsPanel({
allowPasswordLogin={allowPasswordLogin}
passwordLoginForced={passwordLoginForced && !allowPasswordLogin}
handleTogglePasswordLogin={handleTogglePasswordLogin}
oidcAutoProvision={oidcAutoProvision}
handleToggleOidcAutoProvision={handleToggleOidcAutoProvision}
externalAutoProvision={externalAutoProvision}
handleToggleExternalAutoProvision={handleToggleExternalAutoProvision}
secondFactorAfterExternalLogin={secondFactorAfterExternalLogin}
handleToggleSecondFactorAfterExternalLogin={
handleToggleSecondFactorAfterExternalLogin
}
oidcSilentLoginDefault={oidcSilentLoginDefault}
oidcSilentLoginDefaultLocked={oidcSilentLoginDefaultLocked}
handleToggleOidcSilentLoginDefault={handleToggleOidcSilentLoginDefault}
allowPasswordReset={allowPasswordReset}
handleTogglePasswordReset={handleTogglePasswordReset}
sessionTimeout={sessionTimeout}
+6 -26
View File
@@ -22,13 +22,10 @@ type GeneralSettingsSectionProps = {
allowPasswordLogin: boolean;
passwordLoginForced?: boolean;
handleTogglePasswordLogin: () => void;
oidcAutoProvision: boolean;
handleToggleOidcAutoProvision: () => void;
externalAutoProvision: boolean;
handleToggleExternalAutoProvision: () => void;
secondFactorAfterExternalLogin: boolean;
handleToggleSecondFactorAfterExternalLogin: () => void;
oidcSilentLoginDefault: boolean;
oidcSilentLoginDefaultLocked: boolean;
handleToggleOidcSilentLoginDefault: () => void;
allowPasswordReset: boolean;
handleTogglePasswordReset: () => void;
sessionTimeout: string;
@@ -54,13 +51,10 @@ export function AdminGeneralSettingsSection({
allowPasswordLogin,
passwordLoginForced,
handleTogglePasswordLogin,
oidcAutoProvision,
handleToggleOidcAutoProvision,
externalAutoProvision,
handleToggleExternalAutoProvision,
secondFactorAfterExternalLogin,
handleToggleSecondFactorAfterExternalLogin,
oidcSilentLoginDefault,
oidcSilentLoginDefaultLocked,
handleToggleOidcSilentLoginDefault,
allowPasswordReset,
handleTogglePasswordReset,
sessionTimeout,
@@ -147,8 +141,8 @@ export function AdminGeneralSettingsSection({
description={t("admin.oidcAutoProvisionDesc")}
>
<AdminToggle
on={oidcAutoProvision}
onToggle={handleToggleOidcAutoProvision}
on={externalAutoProvision}
onToggle={handleToggleExternalAutoProvision}
/>
</SettingRow>
<SettingRow
@@ -160,20 +154,6 @@ export function AdminGeneralSettingsSection({
onToggle={handleToggleSecondFactorAfterExternalLogin}
/>
</SettingRow>
<SettingRow
label={t("admin.oidcSilentLoginDefault")}
description={
oidcSilentLoginDefaultLocked
? t("admin.oidcSilentLoginDefaultLockedDesc")
: t("admin.oidcSilentLoginDefaultDesc")
}
>
<AdminToggle
on={oidcSilentLoginDefault}
onToggle={handleToggleOidcSilentLoginDefault}
disabled={oidcSilentLoginDefaultLocked}
/>
</SettingRow>
<SettingRow
label={t("admin.allowPasswordReset")}
description={t("admin.allowPasswordResetDesc")}
+5 -5
View File
@@ -2,9 +2,9 @@ import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import {
assignRoleToUser,
linkOIDCToPasswordAccount,
linkExternalToPasswordAccount,
removeRoleFromUser,
unlinkOIDCFromPasswordAccount,
unlinkExternalFromPasswordAccount,
} from "@/main-axios";
import type { Role, UserRole } from "@/main-axios";
import { Button } from "@/components/button";
@@ -412,7 +412,7 @@ export function AdminUnlinkAccountDialog({
if (!unlinkAccountTarget) return;
setSubmitting(true);
try {
await unlinkOIDCFromPasswordAccount(unlinkAccountTarget.id);
await unlinkExternalFromPasswordAccount(unlinkAccountTarget.id);
toast.success(t("admin.unlinkAccountSuccess"));
onSuccess(unlinkAccountTarget.id);
onOpenChange(false);
@@ -498,7 +498,7 @@ export function AdminLinkAccountDialog({
setSubmitting(true);
try {
if (isOidcInitiator) {
await linkOIDCToPasswordAccount(linkAccountTarget.id, trimmed);
await linkExternalToPasswordAccount(linkAccountTarget.id, trimmed);
setUsers((prev) => prev.filter((u) => u.id !== linkAccountTarget.id));
} else {
const oidcUser = users.find(
@@ -508,7 +508,7 @@ export function AdminLinkAccountDialog({
toast.error(t("admin.linkAccountOidcNotFound"));
return;
}
await linkOIDCToPasswordAccount(
await linkExternalToPasswordAccount(
oidcUser.id,
linkAccountTarget.username,
);
+31 -120
View File
@@ -1,15 +1,8 @@
import { getTabType, isPersistentTabType } from "@/shell/tab-registry";
import { ComponentSlot } from "@/shell/ActionSlot";
import { useState, useEffect, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
ExternalLink,
Plug,
Search,
X,
Pencil,
Check,
SquareTerminal,
} from "lucide-react";
import { ExternalLink, Plug, Search, X, Pencil, Check } from "lucide-react";
import {
getActiveSessions,
deleteOpenTab,
@@ -20,7 +13,6 @@ import { tabIcon } from "@/shell/tabUtils";
import { getSessionTimeoutMinutes } from "@/api/open-tabs-api";
import type { Tab, TabType } from "@/types/ui-types";
import { Badge } from "@/components/badge";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
Tooltip,
@@ -64,11 +56,7 @@ function sessionsUnchanged(
a.hostName !== b.hostName ||
a.tabInstanceId !== b.tabInstanceId ||
a.isConnected !== b.isConnected ||
a.createdAt !== b.createdAt ||
a.isOwnSession !== b.isOwnSession ||
a.sharedByUsername !== b.sharedByUsername ||
a.permissionLevel !== b.permissionLevel ||
a.shareId !== b.shareId
a.createdAt !== b.createdAt
) {
return false;
}
@@ -296,7 +284,6 @@ export function ConnectionsPanel({
onForgetBackground,
onRenameTab,
onReorderTabs,
onJoinSharedSession,
}: {
tabs: Tab[];
activeTabId: string;
@@ -311,7 +298,6 @@ export function ConnectionsPanel({
onForgetBackground: (recordId: string) => void;
onRenameTab?: (tabId: string, newLabel: string) => void;
onReorderTabs?: (tabs: Tab[]) => void;
onJoinSharedSession?: (session: ActiveSessionInfo) => void;
}) {
const { t } = useTranslation();
const [now, setNow] = useState(Date.now());
@@ -360,22 +346,6 @@ export function ConnectionsPanel({
})
: backgroundTabs;
const joinedInstanceIds = new Set(
tabs
.map((t) => (t.joinSharedSessionId ? t.instanceId : null))
.filter(Boolean),
);
const sharedWithMe = activeSessions.filter(
(s) => s.isOwnSession === false && !joinedInstanceIds.has(s.sessionId),
);
const filteredSharedWithMe = q
? sharedWithMe.filter(
(s) =>
s.hostName.toLowerCase().includes(q) ||
(s.sharedByUsername ?? "").toLowerCase().includes(q),
)
: sharedWithMe;
// Duration labels only need minute-level freshness; 1s ticks re-render the whole panel.
usePageVisibleInterval(() => setNow(Date.now()), 15_000);
@@ -462,26 +432,36 @@ export function ConnectionsPanel({
activeSessions.map((s) => [s.tabInstanceId, s]),
);
const hasAnything =
openTabs.length > 0 || backgroundTabs.length > 0 || sharedWithMe.length > 0;
const hasAnything = openTabs.length > 0 || backgroundTabs.length > 0;
const hasResults =
filteredOpenTabs.length > 0 ||
filteredBackgroundTabs.length > 0 ||
filteredSharedWithMe.length > 0;
filteredOpenTabs.length > 0 || filteredBackgroundTabs.length > 0;
const pluginSections = (
<ComponentSlot
slotId="connections.sections"
props={{
search: q,
openTabData: tabs.map((tab) => tab.data ?? {}),
}}
/>
);
if (!hasAnything) {
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center py-16">
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
<Plug className="size-5 text-muted-foreground/30" />
</div>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-muted-foreground/60">
{t("connections.noConnections")}
</span>
<span className="text-xs text-muted-foreground/40">
{t("connections.noConnectionsDesc")}
</span>
<div className="flex flex-col flex-1">
{pluginSections}
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center py-16">
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
<Plug className="size-5 text-muted-foreground/30" />
</div>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-muted-foreground/60">
{t("connections.noConnections")}
</span>
<span className="text-xs text-muted-foreground/40">
{t("connections.noConnectionsDesc")}
</span>
</div>
</div>
</div>
);
@@ -629,77 +609,8 @@ export function ConnectionsPanel({
</div>
)}
{filteredSharedWithMe.length > 0 && (
<div
className={`flex flex-col ${filteredOpenTabs.length > 0 || filteredBackgroundTabs.length > 0 ? "mt-2" : ""}`}
>
<SectionHeader
label={t("connections.sectionSharedWithMe")}
count={filteredSharedWithMe.length}
/>
{filteredSharedWithMe.map((session) => (
<SharedWithMeRow
key={session.sessionId}
session={session}
onJoin={() => onJoinSharedSession?.(session)}
/>
))}
</div>
)}
</div>
);
}
function SharedWithMeRow({
session,
onJoin,
}: {
session: ActiveSessionInfo;
onJoin: () => void;
}) {
const { t } = useTranslation();
const isReadWrite = session.permissionLevel === "read-write";
return (
<div className="group flex items-center gap-2.5 px-3 py-2.5 border-b border-border/40 last:border-b-0">
<div className="shrink-0 flex items-center justify-center size-7 rounded bg-muted/60 text-muted-foreground">
<SquareTerminal className="size-3.5" />
</div>
<div className="flex flex-col flex-1 min-w-0 gap-0.5">
<div className="flex items-center gap-1.5 min-w-0">
<span
className={`shrink-0 size-1.5 rounded-full ${
session.isConnected ? "bg-green-500" : "bg-muted-foreground/30"
}`}
/>
<span className="text-xs font-semibold truncate flex-1 text-foreground">
{session.hostName}
</span>
<Badge
variant="outline"
className={`text-[9px] px-1 py-0 h-4 font-mono shrink-0 border-border/60 ${
isReadWrite ? "text-accent-brand" : "text-muted-foreground/60"
}`}
>
{isReadWrite
? t("sessionSharing.permissionLevel.readWrite")
: t("sessionSharing.permissionLevel.readOnly")}
</Badge>
</div>
<span className="text-[10px] text-muted-foreground/60 truncate pl-3">
{t("connections.sharedBy", {
username: session.sharedByUsername ?? "?",
})}
</span>
</div>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={onJoin}
>
{t("connections.join")}
</Button>
{/* Sections a plugin adds, such as sessions others shared with you. */}
{pluginSections}
</div>
);
}
-450
View File
@@ -1,450 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronRight,
Copy,
FolderOpen,
KeyRound,
Loader2,
Pencil,
Plus,
Trash2,
Upload,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { getCredentialDetails } from "@/main-axios";
import { copyToClipboard } from "@/lib/clipboard";
import { ComponentSlot } from "@/shell/ActionSlot";
import type { Host, Credential } from "@/types/ui-types";
type CredentialWithCertificate = Credential & { certPublicKey?: string };
type ConfirmDialog = {
message: string;
onConfirm: () => void;
};
function CredentialItem({
cred,
usedByHosts,
stripeIndex,
onDeploy,
onEdit,
onDelete,
}: {
cred: Credential;
usedByHosts: Host[];
stripeIndex: number;
onDeploy: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
const isKey = cred.type === "key";
const visibleHosts = usedByHosts.slice(0, 2);
const remainingHosts = Math.max(0, usedByHosts.length - visibleHosts.length);
const usedByTitle = usedByHosts
.map((host) => host.name || host.ip)
.join(", ");
return (
<div
className={`group relative flex items-stretch cursor-default select-none transition-colors hover:bg-muted/40 ${stripeIndex % 2 === 1 ? "bg-muted/20" : ""}`}
>
{/* Type stripe */}
<div className="w-[3px] shrink-0 bg-transparent" />
<div className="flex flex-col flex-1 min-w-0 px-2.5 pt-2 pb-1.5 gap-1">
{/* Name row */}
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[13px] font-medium truncate text-foreground leading-none">
{cred.name}
</span>
<span
className={`text-[9px] px-1 py-px font-bold border leading-none shrink-0 ${isKey ? "border-accent-brand/30 text-accent-brand" : "border-border/60 text-muted-foreground/60"}`}
>
{isKey ? "KEY" : "PWD"}
</span>
<ComponentSlot
slotId="credentials.badges"
props={{ credentialId: Number(cred.id) }}
/>
</div>
{/* Username row */}
{cred.username && (
<span className="text-[11px] text-muted-foreground/45 truncate leading-none pl-3">
{cred.username}
</span>
)}
{usedByHosts.length > 0 && (
<div
className="flex min-w-0 items-center gap-1 overflow-hidden pl-3"
title={usedByTitle}
aria-label={`Used by ${usedByTitle}`}
>
{visibleHosts.map((host) => {
const label = host.name || host.ip;
return (
<span
key={host.id}
className="max-w-[92px] truncate border border-accent-brand/25 bg-accent-brand/10 px-1.5 py-px text-[9px] font-semibold leading-none text-accent-brand/80"
title={label}
>
{label}
</span>
);
})}
{remainingHosts > 0 && (
<span
className="shrink-0 border border-border/50 bg-muted/30 px-1.5 py-px text-[9px] font-semibold leading-none text-muted-foreground/70"
title={usedByTitle}
>
+{remainingHosts}
</span>
)}
</div>
)}
{/* Tag pills */}
{cred.tags && cred.tags.length > 0 && (
<div className="flex items-center gap-1 min-w-0 overflow-hidden pl-3">
{cred.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="text-[9px] px-1 py-px border border-border/50 bg-muted/30 text-muted-foreground/60 lowercase shrink-0 leading-none"
>
{tag}
</span>
))}
{cred.tags.length > 4 && (
<span className="text-[9px] text-muted-foreground/40 shrink-0">
+{cred.tags.length - 4}
</span>
)}
</div>
)}
{/* Action tray — slides open on hover */}
<div className="overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 group-hover:max-h-[60px] group-hover:opacity-100">
<div className="flex items-center gap-1 pt-1.5 pl-2 pb-1 border-t border-border/40 mt-0.5">
{isKey && (
<>
<button
title="Deploy key to host"
onClick={onDeploy}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Upload className="size-3.5" />
</button>
<button
title="Copy deploy command"
onClick={async () => {
const pubKey = cred.publicKey;
if (!pubKey) {
toast.error(t("credentials.noPublicKeyAvailable"));
return;
}
const cmd = `mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo "${pubKey}" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`;
const ok = await copyToClipboard(cmd);
if (ok) toast.success(t("credentials.deployCommandCopied"));
else toast.error(t("common.copyFailed"));
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Copy className="size-3.5" />
</button>
</>
)}
<button
title="Edit credential"
onClick={onEdit}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Pencil className="size-3.5" />
</button>
<button
title="Delete credential"
onClick={onDelete}
className="flex items-center justify-center size-7 rounded text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="size-3.5" />
</button>
</div>
</div>
</div>
</div>
);
}
function CredentialFolderItem({
folder,
creds,
allHosts,
stripeOffset,
editingFolderName,
editingFolderValue,
onEditingFolderNameChange,
onEditingFolderValueChange,
onRenameFolder,
onDeploy,
onEdit,
onDelete,
}: {
folder: string;
creds: Credential[];
allHosts: Host[];
stripeOffset: number;
editingFolderName: string | null;
editingFolderValue: string;
onEditingFolderNameChange: (name: string | null) => void;
onEditingFolderValueChange: (value: string) => void;
onRenameFolder: (folder: string, newName: string) => Promise<void>;
onDeploy: (cred: Credential) => void;
onEdit: (cred: Credential) => void;
onDelete: (cred: Credential) => void;
}) {
const [open, setOpen] = useState(false);
return (
<div>
<button
onClick={() => setOpen((v) => !v)}
className={`group/folder flex items-center gap-2 w-full px-3 py-2 hover:bg-muted/50 transition-colors text-left cursor-pointer ${stripeOffset % 2 === 1 ? "bg-muted/20" : ""}`}
>
<ChevronRight
className={`size-3 shrink-0 text-muted-foreground/50 transition-transform ${open ? "rotate-90" : ""}`}
/>
<FolderOpen
className={`size-3.5 shrink-0 ${open ? "text-accent-brand" : "text-muted-foreground/60"}`}
/>
{editingFolderName === folder ? (
<>
<input
autoFocus
value={editingFolderValue}
onChange={(e) => onEditingFolderValueChange(e.target.value)}
onBlur={async () => {
const newName = editingFolderValue.trim();
onEditingFolderNameChange(null);
if (newName && newName !== folder) {
await onRenameFolder(folder, newName);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
if (e.key === "Escape") onEditingFolderNameChange(null);
}}
onClick={(e) => e.stopPropagation()}
className="text-[10px] font-semibold bg-background border border-accent-brand/60 px-1 outline-none text-foreground min-w-0 flex-1"
/>
<button
onClick={(e) => {
e.stopPropagation();
onEditingFolderNameChange(null);
}}
className="text-muted-foreground hover:text-foreground shrink-0"
>
<X className="size-3" />
</button>
</>
) : (
<>
<span className="text-[13px] font-semibold text-foreground/80 truncate flex-1">
{folder}
</span>
<span className="text-[10px] tabular-nums shrink-0 ml-1 text-muted-foreground/40">
{creds.length}
</span>
{folder !== "Uncategorized" && (
<span
className="opacity-0 group-hover/folder:opacity-100 transition-opacity ml-1 text-muted-foreground/50 hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onEditingFolderNameChange(folder);
onEditingFolderValueChange(folder);
}}
>
<Pencil className="size-2.5" />
</span>
)}
</>
)}
</button>
{open && (
<div className="border-l border-border/40 ml-[30px]">
{creds.map((cred, i) => {
const usedByHosts = allHosts.filter(
(h) => h.credentialId === cred.id,
);
return (
<CredentialItem
key={cred.id}
cred={cred}
usedByHosts={usedByHosts}
stripeIndex={stripeOffset + 1 + i}
onDeploy={() => onDeploy(cred)}
onEdit={() => onEdit(cred)}
onDelete={() => onDelete(cred)}
/>
);
})}
</div>
)}
</div>
);
}
export function HostCredentialList({
credentialFolders,
filteredCredentials,
credentialsLoading,
allHosts,
editingFolderName,
editingFolderValue,
onEditingFolderNameChange,
onEditingFolderValueChange,
onRenameFolder,
onDeployCredential,
onEditCredential,
onDeleteCredential,
onAddCredential,
onConfirmDialogChange,
}: {
credentialFolders: string[];
filteredCredentials: Credential[];
credentialsLoading: boolean;
allHosts: Host[];
editingFolderName: string | null;
editingFolderValue: string;
onEditingFolderNameChange: (name: string | null) => void;
onEditingFolderValueChange: (value: string) => void;
onRenameFolder: (folder: string, newName: string) => Promise<void>;
onDeployCredential: (cred: Credential) => void;
onEditCredential: (cred: Credential) => void;
onDeleteCredential: (cred: Credential) => Promise<void>;
onAddCredential: () => void;
onConfirmDialogChange: (dialog: ConfirmDialog) => void;
}) {
const { t } = useTranslation();
async function handleDelete(cred: Credential) {
onConfirmDialogChange({
message: t("hosts.deleteCredentialConfirm", { name: cred.name }),
onConfirm: async () => {
try {
await onDeleteCredential(cred);
toast.success(t("hosts.deletedCredential", { name: cred.name }));
} catch {
toast.error(t("hosts.failedToDeleteCredential2"));
}
},
});
}
async function handleEdit(cred: Credential) {
try {
const full = await getCredentialDetails(Number(cred.id));
onEditCredential({
...cred,
value: (
full as CredentialWithCertificate & {
hasKey?: boolean;
hasKeyPassword?: boolean;
}
).hasKey
? "existing_key"
: ((
full as CredentialWithCertificate & {
password?: string;
}
).password ?? ""),
password:
(full as CredentialWithCertificate & { password?: string })
.password ?? "",
passphrase: (
full as CredentialWithCertificate & {
hasKeyPassword?: boolean;
}
).hasKeyPassword
? "existing_key_password"
: "",
publicKey:
(full as CredentialWithCertificate).certPublicKey ?? cred.publicKey,
});
} catch {
onEditCredential(cred);
}
}
let globalStripe = 0;
return (
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col">
{credentialFolders.map((folder) => {
const creds = filteredCredentials.filter(
(c) => (c.folder || "Uncategorized") === folder,
);
if (creds.length === 0) return null;
const offset = globalStripe;
globalStripe += 1 + creds.length;
return (
<CredentialFolderItem
key={folder}
folder={folder}
creds={creds}
allHosts={allHosts}
stripeOffset={offset}
editingFolderName={editingFolderName}
editingFolderValue={editingFolderValue}
onEditingFolderNameChange={onEditingFolderNameChange}
onEditingFolderValueChange={onEditingFolderValueChange}
onRenameFolder={onRenameFolder}
onDeploy={onDeployCredential}
onEdit={handleEdit}
onDelete={handleDelete}
/>
);
})}
{credentialsLoading && (
<div className="flex flex-col px-2 py-2 space-y-1.5">
{[60, 45, 55, 40].map((w, i) => (
<div key={i} className="flex items-center gap-2 px-3 py-2">
<div className="size-3 rounded-sm bg-muted/50 animate-pulse shrink-0" />
<div
className="h-3 rounded bg-muted/50 animate-pulse"
style={{ width: `${w * 2}px` }}
/>
</div>
))}
<div className="flex items-center justify-center gap-2 pt-2 text-muted-foreground/40">
<Loader2 className="size-3.5 animate-spin" />
<span className="text-xs">{t("hosts.loadingCredentials")}</span>
</div>
</div>
)}
{!credentialsLoading && filteredCredentials.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center px-4">
<KeyRound className="size-8 text-muted-foreground/20 mb-2" />
<span className="text-sm font-semibold text-muted-foreground/60">
{t("hosts.noCredentialsFound")}
</span>
<Button
variant="outline"
size="sm"
className="mt-3 h-7 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
onClick={onAddCredential}
>
<Plus className="size-3 mr-1" />
{t("hosts.addCredentialBtn2")}
</Button>
</div>
)}
</div>
</div>
);
}
+5 -9
View File
@@ -21,7 +21,6 @@ import {
import { getHostDefaults } from "@/api/settings-api";
import type { Host } from "@/types/ui-types";
import type { SSHHost } from "@/types";
import { useTabsSafe } from "@/shell/TabContext";
import { updatePluginHostSettings } from "@/api/plugins-api";
import {
applyHostDraft,
@@ -93,7 +92,6 @@ export function HostEditor({
}) {
const { t } = useTranslation();
const hostSettingPlugins = usePluginHostSections();
const { setPreviewTerminalTheme } = useTabsSafe();
const [form, setForm] = useState(() =>
applyHostDraft(createHostEditorForm(host), host ? undefined : draft),
);
@@ -124,7 +122,7 @@ export function HostEditor({
useState<CredentialOption | null>(null);
useEffect(() => {
getUserInfo()
.then((info) => setIsOidcUser(info.is_oidc))
.then((info) => setIsOidcUser(info.is_external ?? info.is_oidc))
.catch(() => {});
}, []);
@@ -196,7 +194,6 @@ export function HostEditor({
);
toast.success(host ? t("hosts.hostUpdated") : t("hosts.hostCreated"));
setPreviewTerminalTheme(null);
onSave(saved);
} catch {
toast.error(t("hosts.failedToSave"));
@@ -1104,12 +1101,11 @@ export function HostEditor({
<Button
variant="ghost"
onClick={() => {
setPreviewTerminalTheme(null);
onBack();
}}
disabled={saving}
>
{t("hosts.guac.cancelBtn")}
{t("hosts.editorCancel")}
</Button>
{!readOnly && (
<Button
@@ -1119,10 +1115,10 @@ export function HostEditor({
disabled={saving}
>
{saving
? t("hosts.guac.savingBtn")
? t("hosts.editorSaving")
: host
? t("hosts.guac.updateHostBtn")
: t("hosts.guac.addHostBtn")}
? t("hosts.editorUpdate")
: t("hosts.editorAdd")}
</Button>
)}
</div>
+15 -3
View File
@@ -1,4 +1,5 @@
import { getErrorMessage } from "../lib/error-message.js";
import { usePluginStore } from "@/plugin-host/plugin-store";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Download, Search } from "lucide-react";
@@ -17,6 +18,7 @@ import { exportAllSSHHosts, type SSHHostWithStatus } from "@/main-axios";
import { isFolder } from "@/sidebar/SidebarTree";
import {
buildExportPayload,
pluginSecretKeys,
hostKey,
maskSecrets,
type ExportPayload,
@@ -135,6 +137,15 @@ export function HostExportDialog({
};
}, [open, withCredentials, t]);
const { records: pluginRecords } = usePluginStore();
const pluginSecrets = useMemo(
() =>
pluginSecretKeys(
[...pluginRecords.values()].map((record) => record.summary),
),
[pluginRecords],
);
const payload = useMemo(() => {
if (!raw) return null;
return buildExportPayload(
@@ -142,12 +153,13 @@ export function HostExportDialog({
scope === "all" ? null : selectedKeys,
groups,
withCredentials,
pluginSecrets,
);
}, [raw, scope, selectedKeys, groups, withCredentials]);
}, [raw, scope, selectedKeys, groups, withCredentials, pluginSecrets]);
const preview = useMemo(() => {
if (!payload) return "";
const masked = maskSecrets(payload);
const masked = maskSecrets(payload, pluginSecrets);
if (masked.hosts.length <= PREVIEW_HOST_LIMIT) {
return JSON.stringify(masked, null, 2);
}
@@ -163,7 +175,7 @@ export function HostExportDialog({
count: payload.hosts.length - PREVIEW_HOST_LIMIT,
})
);
}, [payload, t]);
}, [payload, pluginSecrets, t]);
function toggleGroup(group: FieldGroup) {
setGroups((prev) => {
+4 -4
View File
@@ -291,10 +291,10 @@ export function HostsPanel({
? t("hosts.filterOnline")
: t("hosts.filterOffline");
if (key === "protocol") return group.toUpperCase();
if (key === "auth")
return t(
`hosts.filterAuth${group.charAt(0).toUpperCase() + group.slice(1)}`,
);
if (key === "auth") {
const labelKey = sshAuthProviders.find(group)?.labelKey;
return labelKey ? t(labelKey, { defaultValue: group }) : group;
}
return group;
}
+2 -4
View File
@@ -647,7 +647,7 @@ export function UserProfilePanel({
getUserInfo()
.then((info) => {
setUserId(info.userId);
setIsOidc(info.is_oidc ?? false);
setIsOidc(info.is_external ?? info.is_oidc ?? false);
setIsDualAuth(info.is_dual_auth ?? false);
const linked = info.linked ?? null;
setLinkedAccount(linked);
@@ -661,7 +661,7 @@ export function UserProfilePanel({
);
if (info.is_dual_auth) {
setAuthMethod(t("newUi.sidebar.userProfile.authMethodDual"));
} else if (info.is_oidc) {
} else if (info.is_external ?? info.is_oidc) {
setAuthMethod(t("newUi.sidebar.userProfile.authMethodOidc"));
} else {
setAuthMethod(t("newUi.sidebar.userProfile.authMethodLocal"));
@@ -764,8 +764,6 @@ export function UserProfilePanel({
// and back silently reset them.
"dashboardTab.slots",
"dashboardTab.mainWidthPct",
"termix-terminal-toolbar-density",
"fileManagerViewMode",
];
const snap: Record<string, string | null> = { __theme: theme };
for (const key of SNAPSHOT_KEYS) snap[key] = localStorage.getItem(key);
@@ -43,10 +43,8 @@ const CREDENTIAL_ITEM_DENSITY_TOKENS = {
export function CredentialItem({
cred,
usedByCount = 0,
query = "",
stripeIndex = 0,
isMenuOpen = false,
onMenuOpenChange,
isTrayOpen = false,
onTrayOpenChange,
onDragStart,
+50 -39
View File
@@ -65,29 +65,42 @@ const CREDENTIAL_KEYS = [
const TUPLE_KEYS = ["name", "ip", "port", "username", "connectionType"];
const NESTED_SECRETS: { container: string; field: string }[] = [
{ container: "guacamoleConfig", field: "gateway-password" },
];
/**
* Keys inside a plugin's JSON host settings that hold secrets, per plugin and
* field, from each manifest's `secretKeys`.
*/
export type PluginSecretKeys = Record<string, Record<string, string[]>>;
/** Reads the secret keys the plugins declare in their host settings. */
export function pluginSecretKeys(
summaries: Array<{
id: string;
contributes?: {
settings?: {
host?: { fields?: Array<{ key: string; secretKeys?: string[] }> };
};
} | null;
}>,
): PluginSecretKeys {
const result: PluginSecretKeys = {};
for (const summary of summaries) {
for (const field of summary.contributes?.settings?.host?.fields ?? []) {
if (!field.secretKeys?.length) continue;
(result[summary.id] ??= {})[field.key] = field.secretKeys;
}
}
return result;
}
const NESTED_SECRET_ARRAYS: { container: string; field: string }[] = [
{ container: "socks5ProxyChain", field: "password" },
];
function nestedSecret(
host: Record<string, unknown>,
container: string,
field: string,
): Record<string, unknown> | null {
const blob = host[container];
if (!blob || typeof blob !== "object") return null;
const record = blob as Record<string, unknown>;
return field in record ? record : null;
}
/** Plugin host settings can carry the same nested secrets as the host. */
function mapPluginSettings(
/** Rewrites each declared secret inside a plugin's JSON host settings. */
function mapPluginSecrets(
pluginSettings: unknown,
apply: (values: Record<string, unknown>) => void,
secrets: PluginSecretKeys,
replace: (value: unknown) => unknown,
): unknown {
if (!pluginSettings || typeof pluginSettings !== "object") {
return pluginSettings;
@@ -97,7 +110,15 @@ function mapPluginSettings(
pluginSettings as Record<string, unknown>,
)) {
const copy = { ...(values as Record<string, unknown>) };
apply(copy);
for (const [field, keys] of Object.entries(secrets[pluginId] ?? {})) {
const blob = copy[field];
if (!blob || typeof blob !== "object") continue;
const record = { ...(blob as Record<string, unknown>) };
for (const key of keys) {
if (key in record) record[key] = replace(record[key]);
}
copy[field] = record;
}
perPlugin[pluginId] = copy;
}
return perPlugin;
@@ -112,6 +133,7 @@ export function buildExportPayload(
selected: Set<string> | null,
groups: Set<FieldGroup>,
withCredentials: boolean,
pluginSecrets: PluginSecretKeys = {},
): ExportPayload {
const allowed = new Set<string>([
"exportId",
@@ -130,16 +152,10 @@ export function buildExportPayload(
if (allowed.has(key)) shaped[key] = value;
}
if (!withCredentials) {
const clearNested = (target: Record<string, unknown>) => {
for (const { container, field } of NESTED_SECRETS) {
const record = nestedSecret(target, container, field);
if (record) target[container] = { ...record, [field]: null };
}
};
clearNested(shaped);
shaped.pluginSettings = mapPluginSettings(
shaped.pluginSettings = mapPluginSecrets(
shaped.pluginSettings,
clearNested,
pluginSecrets,
() => null,
);
for (const { container, field } of NESTED_SECRET_ARRAYS) {
const arr = shaped[container];
@@ -169,7 +185,10 @@ export function buildExportPayload(
return result;
}
export function maskSecrets(payload: ExportPayload): ExportPayload {
export function maskSecrets(
payload: ExportPayload,
pluginSecrets: PluginSecretKeys = {},
): ExportPayload {
return {
...payload,
hosts: payload.hosts.map((host) => {
@@ -183,18 +202,10 @@ export function maskSecrets(payload: ExportPayload): ExportPayload {
masked[key] = "<included>";
}
}
const maskNested = (target: Record<string, unknown>) => {
for (const { container, field } of NESTED_SECRETS) {
const record = nestedSecret(target, container, field);
if (record && record[field]) {
target[container] = { ...record, [field]: "<included>" };
}
}
};
maskNested(masked);
masked.pluginSettings = mapPluginSettings(
masked.pluginSettings = mapPluginSecrets(
masked.pluginSettings,
maskNested,
pluginSecrets,
(value) => (value ? "<included>" : value),
);
for (const { container, field } of NESTED_SECRET_ARRAYS) {
const arr = masked[container];