From 7231a575119bcd9450256f34f16994f7376e084e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 10:26:14 -0700 Subject: [PATCH] feat(dashboard): Secrets cross-node sync passphrase moves below the list, collapsed behind a disclosure The passphrase section now renders below the secrets list as a click-to-expand disclosure (closed by default), keeping all set/rotate/clear behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dashboard/app/components/SecretsView.css | 27 +++++++++ .../dashboard/app/components/SecretsView.tsx | 58 +++++++++++++------ .../components/__tests__/SecretsView.test.tsx | 15 ++++- 3 files changed, 82 insertions(+), 18 deletions(-) diff --git a/packages/dashboard/app/components/SecretsView.css b/packages/dashboard/app/components/SecretsView.css index a2d8713967..3feee620e3 100644 --- a/packages/dashboard/app/components/SecretsView.css +++ b/packages/dashboard/app/components/SecretsView.css @@ -80,6 +80,33 @@ exactly when the surrounding chrome is gone. gap: var(--space-md); } +/* +FNXC:Secrets 2026-06-23-01:30: +Cross-node sync passphrase now renders below the secrets list, collapsed behind a disclosure. The toggle is a full-width +borderless button (theme tokens only) whose chevron is supplied by the lucide icon in markup; the panel only mounts when +expanded so spacing collapses when closed. +*/ +.secrets-sync-disclosure-toggle { + display: flex; + align-items: center; + gap: var(--space-sm); + width: 100%; + padding: 0; + background: none; + border: none; + color: var(--text); + font-size: 1em; + font-weight: 600; + cursor: pointer; + text-align: left; +} + +.secrets-sync-disclosure-panel { + display: flex; + flex-direction: column; + gap: var(--space-md); +} + .secrets-sync-status { display: inline-flex; align-items: center; diff --git a/packages/dashboard/app/components/SecretsView.tsx b/packages/dashboard/app/components/SecretsView.tsx index c6da484340..ec6d87a350 100644 --- a/packages/dashboard/app/components/SecretsView.tsx +++ b/packages/dashboard/app/components/SecretsView.tsx @@ -1,7 +1,7 @@ import "./SecretsView.css"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Check, Copy, Eye, EyeOff, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; +import { Check, ChevronDown, ChevronRight, Copy, Eye, EyeOff, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; type ToastKind = "info" | "success" | "error"; type SecretScope = "project" | "global"; @@ -73,6 +73,13 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => { const [syncPassphrase, setSyncPassphrase] = useState(""); const [syncPassphraseConfirm, setSyncPassphraseConfirm] = useState(""); const [syncSaving, setSyncSaving] = useState(false); + /* + FNXC:Secrets 2026-06-23-01:30: + The cross-node sync passphrase is an advanced, rarely-touched setting, so it now lives BELOW the secrets list and is + collapsed behind a disclosure that is closed by default. Users click the toggle to expand the passphrase status/actions + + description. All set/rotate/clear functionality is unchanged; only relocated and gated behind this toggle. + */ + const [syncDisclosureOpen, setSyncDisclosureOpen] = useState(false); const revealTimersRef = useRef>>(new Map()); const copyTimersRef = useRef>>(new Map()); @@ -286,22 +293,6 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => { -
-
-
-

{t("secrets.syncPassphraseTitle", "Cross-Node Sync Passphrase")}

-

-
-
- - {syncPassphraseConfigured ? : null} -
-
-

- {t("secrets.syncPassphraseDescription", "Shared passphrase used to wrap cross-node secret bundles. Both nodes in a sync pair must share the same value. Stored locally only; never transmitted.")} -

-
- {error ?
{error}
: null} {loading ?
{t("secrets.loading", "Loading…")}
: null} {!loading && sortedSecrets.length === 0 ?
{t("secrets.empty", "No secrets found.")}
: null} @@ -355,6 +346,39 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => { })} + {/* + FNXC:Secrets 2026-06-23-01:30: + Disclosure (closed by default) sits below the secrets list. The toggle button carries aria-expanded/aria-controls + and a rotating chevron; the passphrase status, set/rotate/clear actions, and description only render when expanded. + */} +
+ + {syncDisclosureOpen ? ( +
+
+

+
+ + {syncPassphraseConfigured ? : null} +
+
+

+ {t("secrets.syncPassphraseDescription", "Shared passphrase used to wrap cross-node secret bundles. Both nodes in a sync pair must share the same value. Stored locally only; never transmitted.")} +

+
+ ) : null} +
+ {syncModalOpen ? (
diff --git a/packages/dashboard/app/components/__tests__/SecretsView.test.tsx b/packages/dashboard/app/components/__tests__/SecretsView.test.tsx index 767b66d74f..8c39588fa9 100644 --- a/packages/dashboard/app/components/__tests__/SecretsView.test.tsx +++ b/packages/dashboard/app/components/__tests__/SecretsView.test.tsx @@ -42,6 +42,13 @@ function expectVisibleActionIcon(button: HTMLElement) { expect(svgStyle.stroke).not.toBe(buttonStyle.backgroundColor); } +// FNXC:Secrets 2026-06-23-01:30: The cross-node sync passphrase status/actions now live behind a collapsed-by-default +// disclosure below the secrets list, so tests must click the toggle before the status text / Set passphrase / Clear +// controls become visible. +async function expandPassphraseDisclosure() { + await userEvent.click(screen.getByTestId("secrets-passphrase-disclosure")); +} + describe("SecretsView", () => { beforeEach(() => { vi.clearAllMocks(); @@ -65,6 +72,7 @@ describe("SecretsView", () => { render(); + await expandPassphraseDisclosure(); expect(await screen.findByText("Not configured")).toBeInTheDocument(); }); @@ -79,6 +87,7 @@ describe("SecretsView", () => { render(); + await expandPassphraseDisclosure(); expect(await screen.findByText("Configured")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument(); }); @@ -94,6 +103,7 @@ describe("SecretsView", () => { render(); + await expandPassphraseDisclosure(); await screen.findByText("Not configured"); expect(screen.queryByRole("link", { name: "Learn more" })).not.toBeInTheDocument(); expect(document.querySelector('a[href^="/docs/secrets.md"]')).toBeNull(); @@ -109,6 +119,7 @@ describe("SecretsView", () => { vi.stubGlobal("fetch", fetchMock); render(); + await expandPassphraseDisclosure(); await screen.findByText("Not configured"); await userEvent.click(screen.getByRole("button", { name: "Set passphrase" })); @@ -134,6 +145,7 @@ describe("SecretsView", () => { vi.stubGlobal("fetch", fetchMock); render(); + await expandPassphraseDisclosure(); await screen.findByText("Not configured"); await userEvent.click(screen.getByRole("button", { name: "Set passphrase" })); @@ -157,6 +169,7 @@ describe("SecretsView", () => { vi.spyOn(window, "confirm").mockReturnValue(true); render(); + await expandPassphraseDisclosure(); await screen.findByText("Configured"); await userEvent.click(screen.getByRole("button", { name: "Clear" })); @@ -277,7 +290,7 @@ describe("SecretsView", () => { ); render(); - await screen.findByText("Not configured"); + await screen.findByTestId("secrets-passphrase-disclosure"); await userEvent.click(screen.getByRole("button", { name: "Add Secret" })); expectVisibleActionIcon(screen.getByRole("button", { name: "Show value" }));