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:
@@ -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;
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
Reference in New Issue
Block a user