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 && (