@shipfox/client-secrets 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/.storybook/main.ts +1 -0
  2. package/.storybook/preview.css +4 -0
  3. package/.storybook/preview.tsx +56 -0
  4. package/.turbo/turbo-build.log +2 -0
  5. package/.turbo/turbo-type$colon$emit.log +1 -0
  6. package/.turbo/turbo-type.log +1 -0
  7. package/CHANGELOG.md +102 -0
  8. package/LICENSE +21 -0
  9. package/dist/components/copy-key.d.ts +8 -0
  10. package/dist/components/copy-key.d.ts.map +1 -0
  11. package/dist/components/copy-key.js +21 -0
  12. package/dist/components/copy-key.js.map +1 -0
  13. package/dist/components/delete-entry-dialog.d.ts +9 -0
  14. package/dist/components/delete-entry-dialog.d.ts.map +1 -0
  15. package/dist/components/delete-entry-dialog.js +66 -0
  16. package/dist/components/delete-entry-dialog.js.map +1 -0
  17. package/dist/components/form-errors.d.ts +17 -0
  18. package/dist/components/form-errors.d.ts.map +1 -0
  19. package/dist/components/form-errors.js +81 -0
  20. package/dist/components/form-errors.js.map +1 -0
  21. package/dist/components/form-shell.d.ts +14 -0
  22. package/dist/components/form-shell.d.ts.map +1 -0
  23. package/dist/components/form-shell.js +28 -0
  24. package/dist/components/form-shell.js.map +1 -0
  25. package/dist/components/secret-form.d.ts +10 -0
  26. package/dist/components/secret-form.d.ts.map +1 -0
  27. package/dist/components/secret-form.js +176 -0
  28. package/dist/components/secret-form.js.map +1 -0
  29. package/dist/components/secret-form.stories.d.ts +9 -0
  30. package/dist/components/secret-form.stories.d.ts.map +1 -0
  31. package/dist/components/secret-form.stories.js +60 -0
  32. package/dist/components/secret-form.stories.js.map +1 -0
  33. package/dist/components/store-key.d.ts +20 -0
  34. package/dist/components/store-key.d.ts.map +1 -0
  35. package/dist/components/store-key.js +25 -0
  36. package/dist/components/store-key.js.map +1 -0
  37. package/dist/components/store-section-shell.d.ts +10 -0
  38. package/dist/components/store-section-shell.d.ts.map +1 -0
  39. package/dist/components/store-section-shell.js +40 -0
  40. package/dist/components/store-section-shell.js.map +1 -0
  41. package/dist/components/variable-form.d.ts +12 -0
  42. package/dist/components/variable-form.d.ts.map +1 -0
  43. package/dist/components/variable-form.js +186 -0
  44. package/dist/components/variable-form.js.map +1 -0
  45. package/dist/components/variable-form.stories.d.ts +9 -0
  46. package/dist/components/variable-form.stories.d.ts.map +1 -0
  47. package/dist/components/variable-form.stories.js +61 -0
  48. package/dist/components/variable-form.stories.js.map +1 -0
  49. package/dist/components/workspace-secrets-section.d.ts +4 -0
  50. package/dist/components/workspace-secrets-section.d.ts.map +1 -0
  51. package/dist/components/workspace-secrets-section.js +251 -0
  52. package/dist/components/workspace-secrets-section.js.map +1 -0
  53. package/dist/components/workspace-secrets-section.stories.d.ts +13 -0
  54. package/dist/components/workspace-secrets-section.stories.d.ts.map +1 -0
  55. package/dist/components/workspace-secrets-section.stories.js +140 -0
  56. package/dist/components/workspace-secrets-section.stories.js.map +1 -0
  57. package/dist/components/workspace-variables-section.d.ts +4 -0
  58. package/dist/components/workspace-variables-section.d.ts.map +1 -0
  59. package/dist/components/workspace-variables-section.js +255 -0
  60. package/dist/components/workspace-variables-section.js.map +1 -0
  61. package/dist/components/workspace-variables-section.stories.d.ts +11 -0
  62. package/dist/components/workspace-variables-section.stories.d.ts.map +1 -0
  63. package/dist/components/workspace-variables-section.stories.js +127 -0
  64. package/dist/components/workspace-variables-section.stories.js.map +1 -0
  65. package/dist/hooks/api/create-store-api.d.ts +56 -0
  66. package/dist/hooks/api/create-store-api.d.ts.map +1 -0
  67. package/dist/hooks/api/create-store-api.js +95 -0
  68. package/dist/hooks/api/create-store-api.js.map +1 -0
  69. package/dist/hooks/api/secrets.d.ts +61 -0
  70. package/dist/hooks/api/secrets.d.ts.map +1 -0
  71. package/dist/hooks/api/secrets.js +15 -0
  72. package/dist/hooks/api/secrets.js.map +1 -0
  73. package/dist/hooks/api/variables.d.ts +88 -0
  74. package/dist/hooks/api/variables.d.ts.map +1 -0
  75. package/dist/hooks/api/variables.js +52 -0
  76. package/dist/hooks/api/variables.js.map +1 -0
  77. package/dist/index.d.ts +6 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +7 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/tsconfig.test.tsbuildinfo +1 -0
  82. package/package.json +89 -0
  83. package/src/components/copy-key.test.ts +44 -0
  84. package/src/components/copy-key.ts +21 -0
  85. package/src/components/delete-entry-dialog.tsx +57 -0
  86. package/src/components/form-errors.test.ts +67 -0
  87. package/src/components/form-errors.ts +59 -0
  88. package/src/components/form-shell.tsx +31 -0
  89. package/src/components/secret-form.stories.tsx +58 -0
  90. package/src/components/secret-form.test.tsx +37 -0
  91. package/src/components/secret-form.tsx +170 -0
  92. package/src/components/store-key.test.ts +53 -0
  93. package/src/components/store-key.ts +36 -0
  94. package/src/components/store-section-shell.tsx +32 -0
  95. package/src/components/variable-form.stories.tsx +58 -0
  96. package/src/components/variable-form.test.tsx +60 -0
  97. package/src/components/variable-form.tsx +182 -0
  98. package/src/components/workspace-secrets-section.stories.tsx +128 -0
  99. package/src/components/workspace-secrets-section.tsx +231 -0
  100. package/src/components/workspace-variables-section.stories.tsx +121 -0
  101. package/src/components/workspace-variables-section.tsx +238 -0
  102. package/src/hooks/api/create-store-api.ts +114 -0
  103. package/src/hooks/api/secrets.test.ts +157 -0
  104. package/src/hooks/api/secrets.ts +26 -0
  105. package/src/hooks/api/variables.ts +61 -0
  106. package/src/index.ts +5 -0
  107. package/test/fixtures/secrets.ts +59 -0
  108. package/test/setup.ts +3 -0
  109. package/tsconfig.build.json +9 -0
  110. package/tsconfig.build.tsbuildinfo +1 -0
  111. package/tsconfig.json +3 -0
  112. package/tsconfig.test.json +8 -0
  113. package/vercel.json +8 -0
  114. package/vitest.config.ts +75 -0
@@ -0,0 +1,53 @@
1
+ import {STORE_KEY_HELP, validateNewStoreKey, validateStoreKey} from './store-key.js';
2
+
3
+ describe('validateStoreKey', () => {
4
+ test('accepts a valid uppercase identifier', () => {
5
+ expect(validateStoreKey('MY_TOKEN')).toBeUndefined();
6
+ });
7
+
8
+ test('rejects a malformed key', () => {
9
+ expect(validateStoreKey('my token')).toBe(STORE_KEY_HELP);
10
+ });
11
+ });
12
+
13
+ describe('validateNewStoreKey', () => {
14
+ test('blocks a create that collides with an existing key', () => {
15
+ const error = validateNewStoreKey('API_TOKEN', {
16
+ mode: 'create',
17
+ reservedKeys: ['API_TOKEN', 'DATABASE_URL'],
18
+ kind: 'secret',
19
+ });
20
+
21
+ expect(error).toBe('A secret with this name already exists. Edit it instead.');
22
+ });
23
+
24
+ test('allows a create with a fresh key', () => {
25
+ const error = validateNewStoreKey('NEW_KEY', {
26
+ mode: 'create',
27
+ reservedKeys: ['API_TOKEN'],
28
+ kind: 'secret',
29
+ });
30
+
31
+ expect(error).toBeUndefined();
32
+ });
33
+
34
+ test('does not treat the locked key as a collision in edit mode', () => {
35
+ const error = validateNewStoreKey('LOG_LEVEL', {
36
+ mode: 'edit',
37
+ reservedKeys: ['LOG_LEVEL'],
38
+ kind: 'variable',
39
+ });
40
+
41
+ expect(error).toBeUndefined();
42
+ });
43
+
44
+ test('enforces the format before the collision check', () => {
45
+ const error = validateNewStoreKey('bad key', {
46
+ mode: 'create',
47
+ reservedKeys: [],
48
+ kind: 'secret',
49
+ });
50
+
51
+ expect(error).toBe(STORE_KEY_HELP);
52
+ });
53
+ });
@@ -0,0 +1,36 @@
1
+ import {secretKeySchema} from '@shipfox/api-secrets-dto';
2
+
3
+ export const STORE_KEY_HELP =
4
+ 'Uppercase letters, digits and underscores; must start with a letter or underscore.';
5
+
6
+ /**
7
+ * Validates a store key against the shared `secretKeySchema` and returns a
8
+ * human-readable message (not Zod's default) when it fails. The name input is
9
+ * also auto-uppercased at the call site, so the common case never trips this.
10
+ */
11
+ export function validateStoreKey(value: string): string | undefined {
12
+ return secretKeySchema.safeParse(value).success ? undefined : STORE_KEY_HELP;
13
+ }
14
+
15
+ /**
16
+ * Format validation plus a create-mode collision guard. `PUT /:key` is an
17
+ * upsert and single writes never return a duplicate-key error, so without this
18
+ * a create with an existing name would silently overwrite the current value —
19
+ * unrecoverable for write-only secrets. Edit mode locks the key, so it skips
20
+ * the collision check.
21
+ */
22
+ export function validateNewStoreKey(
23
+ value: string,
24
+ {
25
+ mode,
26
+ reservedKeys,
27
+ kind,
28
+ }: {mode: 'create' | 'edit'; reservedKeys: readonly string[]; kind: 'secret' | 'variable'},
29
+ ): string | undefined {
30
+ const formatError = validateStoreKey(value);
31
+ if (formatError) return formatError;
32
+ if (mode === 'create' && reservedKeys.includes(value)) {
33
+ return `A ${kind} with this name already exists. Edit it instead.`;
34
+ }
35
+ return undefined;
36
+ }
@@ -0,0 +1,32 @@
1
+ import {Skeleton} from '@shipfox/react-ui/skeleton';
2
+ import {cn} from '@shipfox/react-ui/utils';
3
+
4
+ export const STORE_SURFACE_CLASS =
5
+ 'overflow-hidden rounded-8 border border-border-neutral-base bg-background-neutral-base';
6
+
7
+ /** Placeholder rows shown while the store list loads. */
8
+ export function StoreRowsSkeleton({label}: {label: string}) {
9
+ return (
10
+ <div role="status" aria-label={label} className={STORE_SURFACE_CLASS}>
11
+ <ul className="divide-y divide-border-neutral-base">
12
+ {[0, 1, 2].map((row) => (
13
+ <li key={row} className="flex items-center gap-12 px-16 py-12">
14
+ <Skeleton className="h-16 w-140" />
15
+ <Skeleton className="h-16 w-96" />
16
+ <Skeleton className="ml-auto h-14 w-80 shrink-0" />
17
+ </li>
18
+ ))}
19
+ </ul>
20
+ </div>
21
+ );
22
+ }
23
+
24
+ export function StoreSurface({
25
+ className,
26
+ children,
27
+ }: {
28
+ className?: string;
29
+ children: React.ReactNode;
30
+ }) {
31
+ return <div className={cn(STORE_SURFACE_CLASS, className)}>{children}</div>;
32
+ }
@@ -0,0 +1,58 @@
1
+ import {Toaster} from '@shipfox/react-ui/toast';
2
+ import type {Meta, StoryObj} from '@storybook/react';
3
+ import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
4
+ import type {ReactNode} from 'react';
5
+ import {useMemo} from 'react';
6
+ import {userEvent, within} from 'storybook/test';
7
+ import {VariableForm} from './variable-form.js';
8
+
9
+ const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
10
+
11
+ function Wrapper({children}: {children: ReactNode}) {
12
+ const queryClient = useMemo(
13
+ () => new QueryClient({defaultOptions: {queries: {retry: false}, mutations: {retry: false}}}),
14
+ [],
15
+ );
16
+ return (
17
+ <QueryClientProvider client={queryClient}>
18
+ <div className="w-[520px] overflow-hidden rounded-8 border border-border-neutral-base bg-background-neutral-base">
19
+ {children}
20
+ </div>
21
+ <Toaster />
22
+ </QueryClientProvider>
23
+ );
24
+ }
25
+
26
+ const meta: Meta<typeof VariableForm> = {
27
+ title: 'Secrets/VariableForm',
28
+ component: VariableForm,
29
+ args: {
30
+ workspaceId: WORKSPACE_ID,
31
+ mode: 'create',
32
+ onSaved: () => undefined,
33
+ onCancel: () => undefined,
34
+ },
35
+ decorators: [
36
+ (Story) => (
37
+ <Wrapper>
38
+ <Story />
39
+ </Wrapper>
40
+ ),
41
+ ],
42
+ };
43
+ export default meta;
44
+
45
+ type Story = StoryObj<typeof VariableForm>;
46
+
47
+ export const Playground: Story = {};
48
+
49
+ export const Edit: Story = {
50
+ args: {mode: 'edit', existingKey: 'LOG_LEVEL', existingValue: 'debug'},
51
+ };
52
+
53
+ export const SensitiveNameWarning: Story = {
54
+ play: async ({canvasElement}) => {
55
+ const canvas = within(canvasElement);
56
+ await userEvent.type(canvas.getByLabelText('Name'), 'MY_TOKEN');
57
+ },
58
+ };
@@ -0,0 +1,60 @@
1
+ import {configureApiClient} from '@shipfox/client-api';
2
+ import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
3
+ import {render, screen, waitFor} from '@testing-library/react';
4
+ import {variable} from '#test/fixtures/secrets.js';
5
+ import {VariableForm} from './variable-form.js';
6
+
7
+ const FULL_VALUE_ERROR = /Could not load the current value/;
8
+
9
+ function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
10
+ return new Response(JSON.stringify(body), {
11
+ headers: {'content-type': 'application/json'},
12
+ status: 200,
13
+ ...init,
14
+ });
15
+ }
16
+
17
+ function renderTruncatedEdit(fetchImpl: typeof fetch) {
18
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
19
+ const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
20
+ return render(
21
+ <QueryClientProvider client={queryClient}>
22
+ <VariableForm
23
+ workspaceId="11111111-1111-4111-8111-111111111111"
24
+ mode="edit"
25
+ existingKey="TLS_CERT"
26
+ existingValue="-----BEGIN CERTIFICATE-----"
27
+ existingValueTruncated
28
+ onSaved={() => undefined}
29
+ onCancel={() => undefined}
30
+ />
31
+ </QueryClientProvider>,
32
+ );
33
+ }
34
+
35
+ describe('VariableForm truncated edit', () => {
36
+ test('loads the full value and enables the update button', async () => {
37
+ const fetchImpl = (() =>
38
+ Promise.resolve(
39
+ jsonResponse({variable: variable({value: 'full-certificate-body'})}),
40
+ )) as unknown as typeof fetch;
41
+
42
+ renderTruncatedEdit(fetchImpl);
43
+
44
+ const value = (await screen.findByLabelText('Value')) as HTMLTextAreaElement;
45
+ await waitFor(() => expect(value.value).toBe('full-certificate-body'));
46
+ expect(screen.getByRole('button', {name: 'Update variable'})).toBeEnabled();
47
+ });
48
+
49
+ test('keeps the update button disabled and surfaces an error when the full value fails to load', async () => {
50
+ const fetchImpl = (() =>
51
+ Promise.resolve(
52
+ jsonResponse({code: 'server-error', message: 'boom'}, {status: 500}),
53
+ )) as unknown as typeof fetch;
54
+
55
+ renderTruncatedEdit(fetchImpl);
56
+
57
+ await screen.findByText(FULL_VALUE_ERROR);
58
+ expect(screen.getByRole('button', {name: 'Update variable'})).toBeDisabled();
59
+ });
60
+ });
@@ -0,0 +1,182 @@
1
+ import {isSensitiveSecretName} from '@shipfox/api-secrets-dto';
2
+ import {Button} from '@shipfox/react-ui/button';
3
+ import {Callout} from '@shipfox/react-ui/callout';
4
+ import {
5
+ FormField,
6
+ FormFieldInput,
7
+ FormFieldTextarea,
8
+ fieldError,
9
+ } from '@shipfox/react-ui/form-field';
10
+ import {Text} from '@shipfox/react-ui/typography';
11
+ import {useForm} from '@tanstack/react-form';
12
+ import {useEffect, useRef, useState} from 'react';
13
+ import {usePutVariableMutation, useVariableQuery} from '#hooks/api/variables.js';
14
+ import {secretsErrorToFormError} from './form-errors.js';
15
+ import {FormBody, FormFooter} from './form-shell.js';
16
+ import {STORE_KEY_HELP, validateNewStoreKey} from './store-key.js';
17
+
18
+ export const VARIABLE_FORM_ID = 'variable-form';
19
+
20
+ export function VariableForm({
21
+ workspaceId,
22
+ mode,
23
+ existingKey,
24
+ existingValue,
25
+ existingValueTruncated = false,
26
+ reservedKeys = [],
27
+ onSaved,
28
+ onCancel,
29
+ }: {
30
+ workspaceId: string;
31
+ mode: 'create' | 'edit';
32
+ existingKey?: string | undefined;
33
+ existingValue?: string | undefined;
34
+ existingValueTruncated?: boolean | undefined;
35
+ reservedKeys?: readonly string[] | undefined;
36
+ onSaved: () => void;
37
+ onCancel: () => void;
38
+ }) {
39
+ const putVariable = usePutVariableMutation();
40
+ const [formError, setFormError] = useState<string | undefined>();
41
+
42
+ // The list value is only a preview, so a truncated variable must load its full
43
+ // value before editing to avoid saving the truncated preview back.
44
+ const needsFullValue = mode === 'edit' && existingValueTruncated;
45
+ const fullValueQuery = useVariableQuery(workspaceId, needsFullValue ? existingKey : undefined);
46
+
47
+ const form = useForm({
48
+ defaultValues: {key: existingKey ?? '', value: existingValue ?? ''},
49
+ onSubmit: async ({value}) => {
50
+ setFormError(undefined);
51
+ try {
52
+ await putVariable.mutateAsync({workspaceId, key: value.key, body: {value: value.value}});
53
+ onSaved();
54
+ } catch (error) {
55
+ const mapped = secretsErrorToFormError(error);
56
+ if (mapped.kind === 'field') {
57
+ form.setFieldMeta(mapped.field, (prev) => ({
58
+ ...prev,
59
+ errorMap: {...prev.errorMap, onServer: mapped.message},
60
+ }));
61
+ } else {
62
+ setFormError(mapped.message);
63
+ }
64
+ }
65
+ },
66
+ });
67
+
68
+ const populatedRef = useRef(false);
69
+ useEffect(() => {
70
+ if (needsFullValue && fullValueQuery.data && !populatedRef.current) {
71
+ populatedRef.current = true;
72
+ form.setFieldValue('value', fullValueQuery.data.value);
73
+ }
74
+ }, [needsFullValue, fullValueQuery.data, form]);
75
+
76
+ // The full value must be loaded before a save is allowed, otherwise submitting would
77
+ // overwrite the stored value with the truncated preview. Block until it arrives —
78
+ // whether the fetch is still pending or has failed.
79
+ const awaitingFullValue = needsFullValue && fullValueQuery.data === undefined;
80
+ const loadingFullValue = needsFullValue && fullValueQuery.isPending;
81
+
82
+ return (
83
+ <>
84
+ <FormBody>
85
+ <form
86
+ id={VARIABLE_FORM_ID}
87
+ className="flex w-full flex-col gap-16"
88
+ noValidate
89
+ onSubmit={(event) => {
90
+ event.preventDefault();
91
+ event.stopPropagation();
92
+ void form.handleSubmit();
93
+ }}
94
+ >
95
+ <form.Field
96
+ name="key"
97
+ validators={{
98
+ onBlur: ({value}) =>
99
+ validateNewStoreKey(value, {mode, reservedKeys, kind: 'variable'}),
100
+ onSubmit: ({value}) =>
101
+ validateNewStoreKey(value, {mode, reservedKeys, kind: 'variable'}),
102
+ }}
103
+ >
104
+ {(field) => (
105
+ <FormField
106
+ label="Name"
107
+ id="variable-key"
108
+ description={STORE_KEY_HELP}
109
+ error={fieldError(field)}
110
+ >
111
+ <FormFieldInput
112
+ className="font-code"
113
+ autoComplete="off"
114
+ spellCheck={false}
115
+ disabled={mode === 'edit'}
116
+ placeholder="LOG_LEVEL"
117
+ value={field.state.value}
118
+ onChange={(event) => field.handleChange(event.target.value.toUpperCase())}
119
+ onBlur={field.handleBlur}
120
+ />
121
+ {isSensitiveSecretName(field.state.value) ? (
122
+ <Callout role="alert" type="warning">
123
+ <Text size="sm" aria-live="polite">
124
+ This looks like it may be sensitive. Variables are stored in plaintext and are
125
+ not redacted from logs. Use a Secret if this value contains private data.
126
+ </Text>
127
+ </Callout>
128
+ ) : null}
129
+ </FormField>
130
+ )}
131
+ </form.Field>
132
+
133
+ <form.Field name="value">
134
+ {(field) => (
135
+ <FormField
136
+ label="Value"
137
+ id="variable-value"
138
+ error={fieldError(field)}
139
+ description={loadingFullValue ? 'Loading the current value…' : undefined}
140
+ >
141
+ <FormFieldTextarea
142
+ className="font-code"
143
+ autoComplete="off"
144
+ spellCheck={false}
145
+ rows={3}
146
+ disabled={awaitingFullValue}
147
+ placeholder="debug"
148
+ value={field.state.value}
149
+ onChange={(event) => field.handleChange(event.target.value)}
150
+ onBlur={field.handleBlur}
151
+ />
152
+ </FormField>
153
+ )}
154
+ </form.Field>
155
+ </form>
156
+ {awaitingFullValue && fullValueQuery.isError ? (
157
+ <Callout role="alert" type="error">
158
+ <Text size="sm">Could not load the current value. Close and try again.</Text>
159
+ </Callout>
160
+ ) : null}
161
+ {formError ? (
162
+ <Callout role="alert" type="error">
163
+ <Text size="sm">{formError}</Text>
164
+ </Callout>
165
+ ) : null}
166
+ </FormBody>
167
+ <FormFooter>
168
+ <Button variant="secondary" type="button" onClick={onCancel}>
169
+ Cancel
170
+ </Button>
171
+ <Button
172
+ type="submit"
173
+ form={VARIABLE_FORM_ID}
174
+ isLoading={putVariable.isPending}
175
+ disabled={awaitingFullValue}
176
+ >
177
+ {mode === 'edit' ? 'Update variable' : 'Create variable'}
178
+ </Button>
179
+ </FormFooter>
180
+ </>
181
+ );
182
+ }
@@ -0,0 +1,128 @@
1
+ import type {SecretDto} from '@shipfox/api-secrets-dto';
2
+ import {configureApiClient} from '@shipfox/client-api';
3
+ import {Toaster} from '@shipfox/react-ui/toast';
4
+ import type {Decorator, Meta, StoryObj} from '@storybook/react';
5
+ import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
6
+ import {useEffect, useMemo} from 'react';
7
+ import {within} from 'storybook/test';
8
+ import {WorkspaceSecretsSection} from './workspace-secrets-section.js';
9
+
10
+ // Freeze the clock only while a story is mounted so RelativeTime renders a stable
11
+ // string in argos snapshots, without leaking the override to other stories.
12
+ const FROZEN_NOW = Date.parse('2026-07-02T00:00:00.000Z');
13
+ const realDateNow = Date.now;
14
+ const withFrozenClock: Decorator = (Story) => {
15
+ Date.now = () => FROZEN_NOW;
16
+ useEffect(
17
+ () => () => {
18
+ Date.now = realDateNow;
19
+ },
20
+ [],
21
+ );
22
+ return <Story />;
23
+ };
24
+
25
+ const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
26
+ const EDITOR_ID = '22222222-2222-4222-8222-222222222222';
27
+
28
+ const SECRETS: SecretDto[] = [
29
+ {
30
+ key: 'API_TOKEN',
31
+ project_id: null,
32
+ created_at: '2026-05-01T10:00:00.000Z',
33
+ updated_at: '2026-06-30T12:00:00.000Z',
34
+ last_edited_by: EDITOR_ID,
35
+ },
36
+ {
37
+ key: 'DATABASE_URL',
38
+ project_id: null,
39
+ created_at: '2026-04-01T10:00:00.000Z',
40
+ updated_at: '2026-06-25T12:00:00.000Z',
41
+ last_edited_by: EDITOR_ID,
42
+ },
43
+ {
44
+ key: 'STRIPE_SECRET_KEY',
45
+ project_id: null,
46
+ created_at: '2026-03-01T10:00:00.000Z',
47
+ updated_at: '2026-06-20T12:00:00.000Z',
48
+ last_edited_by: null,
49
+ },
50
+ ];
51
+
52
+ type Scenario = 'loaded' | 'empty' | 'loading' | 'error';
53
+
54
+ const RETRY_BUTTON = /retry/i;
55
+
56
+ function fetchForScenario(scenario: Scenario): typeof fetch {
57
+ return (() => {
58
+ if (scenario === 'loading') return new Promise<Response>(() => undefined);
59
+ if (scenario === 'error') {
60
+ return Promise.resolve(
61
+ new Response(JSON.stringify({code: 'server-error', message: 'Something failed'}), {
62
+ status: 500,
63
+ headers: {'content-type': 'application/json'},
64
+ }),
65
+ );
66
+ }
67
+ const secrets = scenario === 'empty' ? [] : SECRETS;
68
+ return Promise.resolve(
69
+ new Response(JSON.stringify({secrets, next_cursor: null}), {
70
+ status: 200,
71
+ headers: {'content-type': 'application/json'},
72
+ }),
73
+ );
74
+ }) as unknown as typeof fetch;
75
+ }
76
+
77
+ function SectionStory({scenario}: {scenario: Scenario}) {
78
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl: fetchForScenario(scenario)});
79
+ const queryClient = useMemo(
80
+ () => new QueryClient({defaultOptions: {queries: {retry: false}}}),
81
+ [],
82
+ );
83
+ return (
84
+ <QueryClientProvider client={queryClient}>
85
+ <div className="w-[720px] p-24">
86
+ <WorkspaceSecretsSection workspaceId={WORKSPACE_ID} />
87
+ </div>
88
+ <Toaster />
89
+ </QueryClientProvider>
90
+ );
91
+ }
92
+
93
+ const meta: Meta<typeof SectionStory> = {
94
+ title: 'Secrets/WorkspaceSecretsSection',
95
+ component: SectionStory,
96
+ decorators: [withFrozenClock],
97
+ };
98
+ export default meta;
99
+
100
+ type Story = StoryObj<typeof SectionStory>;
101
+
102
+ export const Loaded: Story = {
103
+ args: {scenario: 'loaded'},
104
+ play: async ({canvasElement}) => {
105
+ const canvas = within(canvasElement);
106
+
107
+ await canvas.findByText('API_TOKEN');
108
+ await canvas.findByRole('button', {name: 'Copy secret name API_TOKEN'});
109
+ },
110
+ };
111
+
112
+ export const Empty: Story = {
113
+ args: {scenario: 'empty'},
114
+ play: async ({canvasElement}) => {
115
+ await within(canvasElement).findByText('No secrets yet');
116
+ },
117
+ };
118
+
119
+ export const Loading: Story = {
120
+ args: {scenario: 'loading'},
121
+ };
122
+
123
+ export const LoadError: Story = {
124
+ args: {scenario: 'error'},
125
+ play: async ({canvasElement}) => {
126
+ await within(canvasElement).findByRole('button', {name: RETRY_BUTTON});
127
+ },
128
+ };