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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-22 10:26:14 -07:00
parent e9a0b72747
commit 7231a57511
3 changed files with 82 additions and 18 deletions

View File

@@ -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;

View File

@@ -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<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const copyTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
@@ -286,22 +293,6 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
</div>
</div>
<article className="card secrets-sync-card">
<div className="secrets-sync-header">
<div>
<h3>{t("secrets.syncPassphraseTitle", "Cross-Node Sync Passphrase")}</h3>
<p className="secrets-sync-status"><span className={`status-dot ${syncPassphraseConfigured ? "status-dot--online" : "status-dot--pending"}`} aria-hidden="true" /> {syncPassphraseConfigured ? t("secrets.syncConfigured", "Configured") : t("secrets.syncNotConfigured", "Not configured")}</p>
</div>
<div className="secrets-sync-actions">
<button className="btn" onClick={() => setSyncModalOpen(true)}>{syncPassphraseConfigured ? t("secrets.rotateSyncPassphrase", "Rotate") : t("secrets.setPassphrase", "Set passphrase")}</button>
{syncPassphraseConfigured ? <button className="btn btn-danger" onClick={() => void clearSyncPassphraseHandler()}>{t("secrets.clearSyncPassphrase", "Clear")}</button> : null}
</div>
</div>
<p className="secrets-sync-copy">
{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.")}
</p>
</article>
{error ? <div className="form-error">{error}</div> : null}
{loading ? <div className="secrets-loading"><RefreshCw {...spinningActionIconProps} /> {t("secrets.loading", "Loading…")}</div> : null}
{!loading && sortedSecrets.length === 0 ? <div className="secrets-empty">{t("secrets.empty", "No secrets found.")}</div> : null}
@@ -355,6 +346,39 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
})}
</div>
{/*
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.
*/}
<article className="card secrets-sync-card secrets-sync-disclosure">
<button
type="button"
className="secrets-sync-disclosure-toggle"
data-testid="secrets-passphrase-disclosure"
aria-expanded={syncDisclosureOpen}
aria-controls="secrets-sync-disclosure-panel"
onClick={() => setSyncDisclosureOpen((open) => !open)}
>
{syncDisclosureOpen ? <ChevronDown size={16} aria-hidden="true" /> : <ChevronRight size={16} aria-hidden="true" />}
<span>{t("secrets.syncPassphraseTitle", "Cross-Node Sync Passphrase")}</span>
</button>
{syncDisclosureOpen ? (
<div id="secrets-sync-disclosure-panel" className="secrets-sync-disclosure-panel">
<div className="secrets-sync-header">
<p className="secrets-sync-status"><span className={`status-dot ${syncPassphraseConfigured ? "status-dot--online" : "status-dot--pending"}`} aria-hidden="true" /> {syncPassphraseConfigured ? t("secrets.syncConfigured", "Configured") : t("secrets.syncNotConfigured", "Not configured")}</p>
<div className="secrets-sync-actions">
<button className="btn" onClick={() => setSyncModalOpen(true)}>{syncPassphraseConfigured ? t("secrets.rotateSyncPassphrase", "Rotate") : t("secrets.setPassphrase", "Set passphrase")}</button>
{syncPassphraseConfigured ? <button className="btn btn-danger" onClick={() => void clearSyncPassphraseHandler()}>{t("secrets.clearSyncPassphrase", "Clear")}</button> : null}
</div>
</div>
<p className="secrets-sync-copy">
{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.")}
</p>
</div>
) : null}
</article>
{syncModalOpen ? (
<div className="modal-overlay open" role="presentation">
<div className="modal" role="dialog" aria-modal="true" aria-label={syncPassphraseConfigured ? t("secrets.rotateSyncPassphraseModalTitle", "Rotate sync passphrase") : t("secrets.setSyncPassphraseModalTitle", "Set sync passphrase")}>

View File

@@ -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(<SecretsView addToast={vi.fn()} />);
await expandPassphraseDisclosure();
expect(await screen.findByText("Not configured")).toBeInTheDocument();
});
@@ -79,6 +87,7 @@ describe("SecretsView", () => {
render(<SecretsView addToast={vi.fn()} />);
await expandPassphraseDisclosure();
expect(await screen.findByText("Configured")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
});
@@ -94,6 +103,7 @@ describe("SecretsView", () => {
render(<SecretsView addToast={vi.fn()} />);
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(<SecretsView addToast={vi.fn()} />);
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(<SecretsView addToast={vi.fn()} />);
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(<SecretsView addToast={vi.fn()} />);
await expandPassphraseDisclosure();
await screen.findByText("Configured");
await userEvent.click(screen.getByRole("button", { name: "Clear" }));
@@ -277,7 +290,7 @@ describe("SecretsView", () => {
);
render(<SecretsView addToast={vi.fn()} />);
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" }));