diff --git a/src/features/instance/config/secrets/index.tsx b/src/features/instance/config/secrets/index.tsx index 174ea2084..fdb7f2637 100644 --- a/src/features/instance/config/secrets/index.tsx +++ b/src/features/instance/config/secrets/index.tsx @@ -133,6 +133,16 @@ export function ConfigSecretsIndex() { onSelectName={onSelectName} nameHeader="Secret" delivery={true} + docsLink={ + + Secrets Docs + + } grantableComponents={grantableComponents} addDescription="The value is encrypted in your browser against the cluster's secrets key — plaintext never reaches the API, the operation log, or disk. It can be replaced or deleted, but never read back." editDescription="The current value can't be shown — it's stored encrypted. Enter a new value to replace it, adjust how applications read it, or delete the secret." diff --git a/src/features/instance/secrets/SecretDeliveryPicker.tsx b/src/features/instance/secrets/SecretDeliveryPicker.tsx index 69d550e57..974f483dd 100644 --- a/src/features/instance/secrets/SecretDeliveryPicker.tsx +++ b/src/features/instance/secrets/SecretDeliveryPicker.tsx @@ -9,6 +9,9 @@ import { ReactNode } from 'react'; import { SecretTier } from './accessExample'; import { SecretAccessExample } from './SecretAccessExample'; +/** The Harper docs hub for the secrets store — the `secrets` accessor, tiers, and change subscriptions. */ +const SECRETS_DOCS_URL = 'https://docs.harperdb.io/reference/v5/security/secrets'; + export function SecretDeliveryPicker({ name, tier, @@ -61,6 +64,14 @@ export function SecretDeliveryPicker({ How to read it in your component + + Learn more about secrets in the Harper docs + ); diff --git a/src/features/instance/secrets/SecretsManager.delivery.test.tsx b/src/features/instance/secrets/SecretsManager.delivery.test.tsx index 5426fadf8..a1d4d5f8c 100644 --- a/src/features/instance/secrets/SecretsManager.delivery.test.tsx +++ b/src/features/instance/secrets/SecretsManager.delivery.test.tsx @@ -47,6 +47,23 @@ function exampleText(): string { return document.querySelector('pre code')?.textContent ?? ''; } +describe('SecretsManager toolbar', () => { + it('renders docsLink at the start of the toolbar, before Refresh/Add', () => { + renderManager({ + onRefresh: vi.fn().mockResolvedValue(undefined), + docsLink: ( + + Secrets Docs + + ), + }); + const docs = screen.getByRole('link', { name: /secrets docs/i }); + const refresh = screen.getByRole('button', { name: /refresh/i }); + // docsLink precedes Refresh in DOM order — it sits at the left, matching the sshKeys/certificates pages. + expect(docs.compareDocumentPosition(refresh) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); +}); + describe('SecretsManager delivery tier — Add', () => { async function openAddWithKeyValue() { fireEvent.click(screen.getByRole('button', { name: /add/i })); @@ -58,10 +75,16 @@ describe('SecretsManager delivery tier — Add', () => { renderManager(); await openAddWithKeyValue(); - // Scoped is the default: the example destructures the accessor, never touches process.env. + // Scoped is the default: the example reads the live accessor and subscribes for rotations, + // never touching process.env. expect(exampleText()).toContain("import { secrets } from 'harper';"); - expect(exampleText()).toContain('const { NEW_KEY } = secrets;'); + expect(exampleText()).toContain('let value = secrets.NEW_KEY;'); + expect(exampleText()).toContain("secrets.subscribe('NEW_KEY')"); expect(exampleText()).not.toContain('process.env'); + + // The example links out to the Harper secrets docs for deeper reading. + const docsLink = screen.getByRole('link', { name: /learn more about secrets/i }); + expect(docsLink.getAttribute('href')).toBe('https://docs.harperdb.io/reference/v5/security/secrets'); }); it('submits a scoped secret with its pending grants', async () => { @@ -126,7 +149,7 @@ describe('SecretsManager delivery tier — Edit', () => { selectedName: 'DB_PASSWORD', renderEditExtras: () =>
grants
, }); - await waitFor(() => expect(exampleText()).toContain('const { DB_PASSWORD } = secrets;')); + await waitFor(() => expect(exampleText()).toContain('let value = secrets.DB_PASSWORD;')); // Tier matches what's stored (scoped, unchanged), so the live grant_secret editor is safe. expect(screen.getByTestId('live-grants')).toBeTruthy(); }); @@ -144,7 +167,7 @@ describe('SecretsManager delivery tier — Edit', () => { // Flip to scoped without saving: the secret is still processEnv server-side, so a live // grant_secret would be rejected — show the "save first" hint instead of the editor. fireEvent.click(screen.getAllByRole('radio')[0]); - await waitFor(() => expect(exampleText()).toContain('const { TOKEN } = secrets;')); + await waitFor(() => expect(exampleText()).toContain('let value = secrets.TOKEN;')); expect(screen.queryByTestId('live-grants')).toBeNull(); expect(screen.getByText(/save this as a scoped secret first/i)).toBeTruthy(); }); diff --git a/src/features/instance/secrets/SecretsManager.tsx b/src/features/instance/secrets/SecretsManager.tsx index 437c3d4dd..ed7020606 100644 --- a/src/features/instance/secrets/SecretsManager.tsx +++ b/src/features/instance/secrets/SecretsManager.tsx @@ -46,6 +46,7 @@ export function SecretsManager({ onSet, onDelete, renderEditExtras, + docsLink, children, delivery = false, deliveryDefaultTier = 'scoped', @@ -73,6 +74,8 @@ export function SecretsManager({ onDelete?: (key: string) => Promise; /** Extra per-secret content for the edit dialog (e.g. a live grants editor). */ renderEditExtras?: (name: string) => ReactNode; + /** A docs link rendered at the START of the toolbar (before Refresh/Add), matching the sshKeys/certificates config pages. */ + docsLink?: ReactNode; /** Extra toolbar actions, rendered after Refresh/Add. */ children?: ReactNode; /** Enable the delivery-tier chooser (process.env vs scoped) + access examples in the dialogs. */ @@ -115,6 +118,7 @@ export function SecretsManager({ isFetching={isFetching} onRowClick={canManage ? onRowClick : undefined} > + {docsLink} {onRefresh && (