@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.
- package/.storybook/main.ts +1 -0
- package/.storybook/preview.css +4 -0
- package/.storybook/preview.tsx +56 -0
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +102 -0
- package/LICENSE +21 -0
- package/dist/components/copy-key.d.ts +8 -0
- package/dist/components/copy-key.d.ts.map +1 -0
- package/dist/components/copy-key.js +21 -0
- package/dist/components/copy-key.js.map +1 -0
- package/dist/components/delete-entry-dialog.d.ts +9 -0
- package/dist/components/delete-entry-dialog.d.ts.map +1 -0
- package/dist/components/delete-entry-dialog.js +66 -0
- package/dist/components/delete-entry-dialog.js.map +1 -0
- package/dist/components/form-errors.d.ts +17 -0
- package/dist/components/form-errors.d.ts.map +1 -0
- package/dist/components/form-errors.js +81 -0
- package/dist/components/form-errors.js.map +1 -0
- package/dist/components/form-shell.d.ts +14 -0
- package/dist/components/form-shell.d.ts.map +1 -0
- package/dist/components/form-shell.js +28 -0
- package/dist/components/form-shell.js.map +1 -0
- package/dist/components/secret-form.d.ts +10 -0
- package/dist/components/secret-form.d.ts.map +1 -0
- package/dist/components/secret-form.js +176 -0
- package/dist/components/secret-form.js.map +1 -0
- package/dist/components/secret-form.stories.d.ts +9 -0
- package/dist/components/secret-form.stories.d.ts.map +1 -0
- package/dist/components/secret-form.stories.js +60 -0
- package/dist/components/secret-form.stories.js.map +1 -0
- package/dist/components/store-key.d.ts +20 -0
- package/dist/components/store-key.d.ts.map +1 -0
- package/dist/components/store-key.js +25 -0
- package/dist/components/store-key.js.map +1 -0
- package/dist/components/store-section-shell.d.ts +10 -0
- package/dist/components/store-section-shell.d.ts.map +1 -0
- package/dist/components/store-section-shell.js +40 -0
- package/dist/components/store-section-shell.js.map +1 -0
- package/dist/components/variable-form.d.ts +12 -0
- package/dist/components/variable-form.d.ts.map +1 -0
- package/dist/components/variable-form.js +186 -0
- package/dist/components/variable-form.js.map +1 -0
- package/dist/components/variable-form.stories.d.ts +9 -0
- package/dist/components/variable-form.stories.d.ts.map +1 -0
- package/dist/components/variable-form.stories.js +61 -0
- package/dist/components/variable-form.stories.js.map +1 -0
- package/dist/components/workspace-secrets-section.d.ts +4 -0
- package/dist/components/workspace-secrets-section.d.ts.map +1 -0
- package/dist/components/workspace-secrets-section.js +251 -0
- package/dist/components/workspace-secrets-section.js.map +1 -0
- package/dist/components/workspace-secrets-section.stories.d.ts +13 -0
- package/dist/components/workspace-secrets-section.stories.d.ts.map +1 -0
- package/dist/components/workspace-secrets-section.stories.js +140 -0
- package/dist/components/workspace-secrets-section.stories.js.map +1 -0
- package/dist/components/workspace-variables-section.d.ts +4 -0
- package/dist/components/workspace-variables-section.d.ts.map +1 -0
- package/dist/components/workspace-variables-section.js +255 -0
- package/dist/components/workspace-variables-section.js.map +1 -0
- package/dist/components/workspace-variables-section.stories.d.ts +11 -0
- package/dist/components/workspace-variables-section.stories.d.ts.map +1 -0
- package/dist/components/workspace-variables-section.stories.js +127 -0
- package/dist/components/workspace-variables-section.stories.js.map +1 -0
- package/dist/hooks/api/create-store-api.d.ts +56 -0
- package/dist/hooks/api/create-store-api.d.ts.map +1 -0
- package/dist/hooks/api/create-store-api.js +95 -0
- package/dist/hooks/api/create-store-api.js.map +1 -0
- package/dist/hooks/api/secrets.d.ts +61 -0
- package/dist/hooks/api/secrets.d.ts.map +1 -0
- package/dist/hooks/api/secrets.js +15 -0
- package/dist/hooks/api/secrets.js.map +1 -0
- package/dist/hooks/api/variables.d.ts +88 -0
- package/dist/hooks/api/variables.d.ts.map +1 -0
- package/dist/hooks/api/variables.js +52 -0
- package/dist/hooks/api/variables.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/package.json +89 -0
- package/src/components/copy-key.test.ts +44 -0
- package/src/components/copy-key.ts +21 -0
- package/src/components/delete-entry-dialog.tsx +57 -0
- package/src/components/form-errors.test.ts +67 -0
- package/src/components/form-errors.ts +59 -0
- package/src/components/form-shell.tsx +31 -0
- package/src/components/secret-form.stories.tsx +58 -0
- package/src/components/secret-form.test.tsx +37 -0
- package/src/components/secret-form.tsx +170 -0
- package/src/components/store-key.test.ts +53 -0
- package/src/components/store-key.ts +36 -0
- package/src/components/store-section-shell.tsx +32 -0
- package/src/components/variable-form.stories.tsx +58 -0
- package/src/components/variable-form.test.tsx +60 -0
- package/src/components/variable-form.tsx +182 -0
- package/src/components/workspace-secrets-section.stories.tsx +128 -0
- package/src/components/workspace-secrets-section.tsx +231 -0
- package/src/components/workspace-variables-section.stories.tsx +121 -0
- package/src/components/workspace-variables-section.tsx +238 -0
- package/src/hooks/api/create-store-api.ts +114 -0
- package/src/hooks/api/secrets.test.ts +157 -0
- package/src/hooks/api/secrets.ts +26 -0
- package/src/hooks/api/variables.ts +61 -0
- package/src/index.ts +5 -0
- package/test/fixtures/secrets.ts +59 -0
- package/test/setup.ts +3 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vercel.json +8 -0
- 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
|
+
};
|