@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
package/package.json ADDED
@@ -0,0 +1,89 @@
1
+ {
2
+ "name": "@shipfox/client-secrets",
3
+ "license": "MIT",
4
+ "version": "0.2.0",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
8
+ "directory": "libs/client/secrets"
9
+ },
10
+ "type": "module",
11
+ "main": "dist/index.js",
12
+ "types": "dist/index.d.ts",
13
+ "imports": {
14
+ "#*": {
15
+ "types": "./src/*",
16
+ "workspace-source": "./src/*",
17
+ "development": "./src/*",
18
+ "default": "./dist/*"
19
+ },
20
+ "#test/*": "./test/*"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "development": {
25
+ "types": "./src/index.ts",
26
+ "default": "./src/index.ts"
27
+ },
28
+ "default": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ }
32
+ }
33
+ },
34
+ "dependencies": {
35
+ "@swc/helpers": "^0.5.17",
36
+ "@tanstack/react-form": "^1.32.0",
37
+ "@shipfox/client-api": "0.2.0",
38
+ "@shipfox/api-secrets-dto": "2.0.0",
39
+ "@shipfox/client-ui": "0.2.0",
40
+ "@shipfox/react-ui": "0.3.1"
41
+ },
42
+ "peerDependencies": {
43
+ "@tanstack/react-query": "^5.101.0",
44
+ "react": "^19.0.0",
45
+ "react-dom": "^19.0.0"
46
+ },
47
+ "devDependencies": {
48
+ "@argos-ci/cli": "^5.0.0",
49
+ "@argos-ci/storybook": "^6.0.0",
50
+ "@storybook/addon-vitest": "^10.3.6",
51
+ "@storybook/react": "^10.0.0",
52
+ "@storybook/react-vite": "^10.0.0",
53
+ "@tailwindcss/vite": "^4.1.13",
54
+ "@tanstack/react-query": "^5.101.0",
55
+ "@testing-library/jest-dom": "^6.6.3",
56
+ "@testing-library/react": "^16.3.2",
57
+ "@testing-library/user-event": "^14.6.1",
58
+ "@types/react": "^19.1.11",
59
+ "@types/react-dom": "^19.1.7",
60
+ "@vitejs/plugin-react": "^6.0.0",
61
+ "@vitest/browser-playwright": "^4.1.5",
62
+ "jsdom": "^29.0.0",
63
+ "react": "^19.1.1",
64
+ "react-dom": "^19.1.1",
65
+ "storybook": "^10.0.0",
66
+ "storybook-addon-pseudo-states": "^10.0.0",
67
+ "tailwindcss": "^4.1.13",
68
+ "vite": "^8.0.3",
69
+ "vitest": "^4.1.5",
70
+ "@shipfox/biome": "1.8.1",
71
+ "@shipfox/client-test-setup": "0.0.2",
72
+ "@shipfox/swc": "1.2.5",
73
+ "@shipfox/typescript": "1.1.6",
74
+ "@shipfox/vite": "1.2.4",
75
+ "@shipfox/vitest": "1.2.2",
76
+ "@shipfox/ts-config": "1.3.8"
77
+ },
78
+ "scripts": {
79
+ "build": "shipfox-swc",
80
+ "check": "shipfox-biome-check",
81
+ "check:fix": "shipfox-biome-check --write",
82
+ "storybook": "storybook dev -p 6013",
83
+ "storybook:build": "storybook build -o storybook-static",
84
+ "test": "shipfox-vitest-run",
85
+ "test:watch": "shipfox-vitest-watch",
86
+ "type": "shipfox-tsc-check",
87
+ "type:emit": "shipfox-tsc-emit"
88
+ }
89
+ }
@@ -0,0 +1,44 @@
1
+ import {toast} from '@shipfox/react-ui/toast';
2
+ import {copyKeyName} from './copy-key.js';
3
+
4
+ vi.mock('@shipfox/react-ui/toast', () => ({
5
+ toast: {success: vi.fn(), error: vi.fn()},
6
+ }));
7
+
8
+ describe('copyKeyName', () => {
9
+ beforeEach(() => {
10
+ vi.clearAllMocks();
11
+ });
12
+
13
+ afterEach(() => {
14
+ vi.unstubAllGlobals();
15
+ });
16
+
17
+ test('writes the bare key name to the clipboard and toasts success', async () => {
18
+ const writeText = vi.fn().mockResolvedValue(undefined);
19
+ vi.stubGlobal('navigator', {clipboard: {writeText}});
20
+
21
+ await copyKeyName('MY_TOKEN');
22
+
23
+ expect(writeText).toHaveBeenCalledExactlyOnceWith('MY_TOKEN');
24
+ expect(toast.success).toHaveBeenCalledExactlyOnceWith('Copied MY_TOKEN');
25
+ expect(toast.error).not.toHaveBeenCalled();
26
+ });
27
+
28
+ test('no-ops safely and warns when the clipboard API is unavailable', async () => {
29
+ vi.stubGlobal('navigator', {});
30
+
31
+ await copyKeyName('MY_TOKEN');
32
+
33
+ expect(toast.error).toHaveBeenCalledOnce();
34
+ });
35
+
36
+ test('surfaces a toast when the clipboard write rejects', async () => {
37
+ const writeText = vi.fn().mockRejectedValue(new Error('denied'));
38
+ vi.stubGlobal('navigator', {clipboard: {writeText}});
39
+
40
+ await copyKeyName('MY_TOKEN');
41
+
42
+ expect(toast.error).toHaveBeenCalledOnce();
43
+ });
44
+ });
@@ -0,0 +1,21 @@
1
+ import {toast} from '@shipfox/react-ui/toast';
2
+
3
+ /**
4
+ * Copies a bare key name (e.g. `MY_TOKEN`) to the clipboard. Guarded because
5
+ * `navigator.clipboard` is undefined in insecure contexts and older browsers —
6
+ * a missing API or a rejected write surfaces a toast instead of an unhandled
7
+ * rejection.
8
+ */
9
+ export async function copyKeyName(key: string): Promise<void> {
10
+ const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
11
+ if (!clipboard?.writeText) {
12
+ toast.error('Clipboard is not available in this browser.');
13
+ return;
14
+ }
15
+ try {
16
+ await clipboard.writeText(key);
17
+ toast.success(`Copied ${key}`);
18
+ } catch {
19
+ toast.error('Could not copy to the clipboard.');
20
+ }
21
+ }
@@ -0,0 +1,57 @@
1
+ import {Button} from '@shipfox/react-ui/button';
2
+ import {Callout} from '@shipfox/react-ui/callout';
3
+ import {
4
+ Modal,
5
+ ModalBody,
6
+ ModalContent,
7
+ ModalFooter,
8
+ ModalHeader,
9
+ ModalTitle,
10
+ } from '@shipfox/react-ui/modal';
11
+ import {Text} from '@shipfox/react-ui/typography';
12
+
13
+ export function DeleteEntryDialog({
14
+ open,
15
+ onOpenChange,
16
+ entryKey,
17
+ isLoading,
18
+ errorMessage,
19
+ onConfirm,
20
+ }: {
21
+ open: boolean;
22
+ onOpenChange: (next: boolean) => void;
23
+ entryKey: string;
24
+ isLoading: boolean;
25
+ errorMessage?: string | undefined;
26
+ onConfirm: () => void;
27
+ }) {
28
+ return (
29
+ <Modal open={open} onOpenChange={onOpenChange}>
30
+ <ModalContent>
31
+ <ModalHeader>
32
+ <ModalTitle>
33
+ Delete <strong>{entryKey}</strong>?
34
+ </ModalTitle>
35
+ </ModalHeader>
36
+ <ModalBody className="gap-16">
37
+ <Text size="sm">
38
+ Workflows that reference <strong>{entryKey}</strong> will fail at their next run.
39
+ </Text>
40
+ {errorMessage ? (
41
+ <Callout role="alert" type="error">
42
+ <Text size="sm">{errorMessage}</Text>
43
+ </Callout>
44
+ ) : null}
45
+ </ModalBody>
46
+ <ModalFooter>
47
+ <Button variant="secondary" onClick={() => onOpenChange(false)}>
48
+ Cancel
49
+ </Button>
50
+ <Button variant="danger" isLoading={isLoading} onClick={onConfirm}>
51
+ Delete
52
+ </Button>
53
+ </ModalFooter>
54
+ </ModalContent>
55
+ </Modal>
56
+ );
57
+ }
@@ -0,0 +1,67 @@
1
+ import {ApiError} from '@shipfox/client-api';
2
+ import {type SecretsFormErrorMapping, secretsErrorToFormError} from './form-errors.js';
3
+
4
+ function apiError(code: string, message = 'server message'): ApiError {
5
+ return new ApiError({code, message, status: 400});
6
+ }
7
+
8
+ describe('secretsErrorToFormError', () => {
9
+ test.each<[string, SecretsFormErrorMapping]>([
10
+ [
11
+ 'invalid-key',
12
+ {
13
+ kind: 'field',
14
+ field: 'key',
15
+ message:
16
+ 'Invalid name. Use uppercase letters, digits and underscores; start with a letter or underscore.',
17
+ },
18
+ ],
19
+ [
20
+ 'duplicate-key',
21
+ {kind: 'field', field: 'key', message: 'A key with this name already exists.'},
22
+ ],
23
+ ['invalid-namespace', {kind: 'form', message: 'Invalid namespace.'}],
24
+ ['value-too-large', {kind: 'form', message: 'This value is too large to store.'}],
25
+ [
26
+ 'workspace-secret-cap-exceeded',
27
+ {kind: 'form', message: 'This workspace has reached its secrets and variables limit.'},
28
+ ],
29
+ [
30
+ 'batch-scope-mismatch',
31
+ {kind: 'form', message: 'All entries in a batch must share the same scope.'},
32
+ ],
33
+ ['secret-not-found', {kind: 'form', message: 'This secret no longer exists.'}],
34
+ ['variable-not-found', {kind: 'form', message: 'This variable no longer exists.'}],
35
+ ['unknown-secret-store', {kind: 'form', message: 'Unknown secret store.'}],
36
+ ['project-not-found', {kind: 'form', message: 'This project no longer exists.'}],
37
+ [
38
+ 'forbidden',
39
+ {
40
+ kind: 'form',
41
+ message: 'You need the workspace admin role to manage secrets and variables.',
42
+ },
43
+ ],
44
+ ])('maps the %s ApiError code', (code, expected) => {
45
+ const result = secretsErrorToFormError(apiError(code));
46
+
47
+ expect(result).toEqual(expected);
48
+ });
49
+
50
+ test('falls back to the server message for an unknown ApiError code', () => {
51
+ const result = secretsErrorToFormError(apiError('some-new-code', 'raw server message'));
52
+
53
+ expect(result).toEqual({kind: 'form', message: 'raw server message'});
54
+ });
55
+
56
+ test('maps a plain Error to a form-level alert with its message', () => {
57
+ const result = secretsErrorToFormError(new Error('boom'));
58
+
59
+ expect(result).toEqual({kind: 'form', message: 'boom'});
60
+ });
61
+
62
+ test('falls back to generic copy for a non-Error throwable', () => {
63
+ const result = secretsErrorToFormError('weird');
64
+
65
+ expect(result).toEqual({kind: 'form', message: 'Something went wrong. Try again.'});
66
+ });
67
+ });
@@ -0,0 +1,59 @@
1
+ import {ApiError} from '@shipfox/client-api';
2
+
3
+ export type SecretsFormField = 'key';
4
+
5
+ /**
6
+ * Classifies a management error into either a field-level message (routed to
7
+ * the `key` field via `errorMap.onServer`) or a form-level message (rendered
8
+ * in an `<Callout role="alert"`). Shared by the secret and variable forms because both hit
9
+ * the same management routes / `ClientError` codes.
10
+ */
11
+ export type SecretsFormErrorMapping =
12
+ | {kind: 'field'; field: SecretsFormField; message: string}
13
+ | {kind: 'form'; message: string};
14
+
15
+ export function secretsErrorToFormError(error: unknown): SecretsFormErrorMapping {
16
+ if (error instanceof ApiError) {
17
+ switch (error.code) {
18
+ case 'invalid-key':
19
+ return {
20
+ kind: 'field',
21
+ field: 'key',
22
+ message:
23
+ 'Invalid name. Use uppercase letters, digits and underscores; start with a letter or underscore.',
24
+ };
25
+ case 'duplicate-key':
26
+ return {kind: 'field', field: 'key', message: 'A key with this name already exists.'};
27
+ case 'invalid-namespace':
28
+ return {kind: 'form', message: 'Invalid namespace.'};
29
+ case 'value-too-large':
30
+ return {kind: 'form', message: 'This value is too large to store.'};
31
+ case 'workspace-secret-cap-exceeded':
32
+ return {
33
+ kind: 'form',
34
+ message: 'This workspace has reached its secrets and variables limit.',
35
+ };
36
+ case 'batch-scope-mismatch':
37
+ return {kind: 'form', message: 'All entries in a batch must share the same scope.'};
38
+ case 'secret-not-found':
39
+ return {kind: 'form', message: 'This secret no longer exists.'};
40
+ case 'variable-not-found':
41
+ return {kind: 'form', message: 'This variable no longer exists.'};
42
+ case 'unknown-secret-store':
43
+ return {kind: 'form', message: 'Unknown secret store.'};
44
+ case 'project-not-found':
45
+ return {kind: 'form', message: 'This project no longer exists.'};
46
+ case 'forbidden':
47
+ return {
48
+ kind: 'form',
49
+ message: 'You need the workspace admin role to manage secrets and variables.',
50
+ };
51
+ default:
52
+ return {kind: 'form', message: error.message};
53
+ }
54
+ }
55
+ if (error instanceof Error) {
56
+ return {kind: 'form', message: error.message};
57
+ }
58
+ return {kind: 'form', message: 'Something went wrong. Try again.'};
59
+ }
@@ -0,0 +1,31 @@
1
+ import {cn} from '@shipfox/react-ui/utils';
2
+ import type {ReactNode} from 'react';
3
+
4
+ /**
5
+ * Modal-agnostic body/footer layout matching `ModalBody`/`ModalFooter` so the
6
+ * forms render the same inside a `ModalContent` and standalone (stories, RTL
7
+ * tests) without a Modal context.
8
+ */
9
+ export function FormBody({children, className}: {children: ReactNode; className?: string}) {
10
+ return (
11
+ <div
12
+ className={cn(
13
+ 'flex w-full flex-col items-start gap-16 bg-background-neutral-base px-24 pt-16 pb-24',
14
+ className,
15
+ )}
16
+ >
17
+ {children}
18
+ </div>
19
+ );
20
+ }
21
+
22
+ export function FormFooter({children}: {children: ReactNode}) {
23
+ return (
24
+ <div className="flex w-full shrink-0 flex-col">
25
+ <div className="h-[1px] w-full bg-border-neutral-strong" />
26
+ <div className="flex w-full items-center justify-end gap-16 bg-background-neutral-base px-24 py-16">
27
+ {children}
28
+ </div>
29
+ </div>
30
+ );
31
+ }
@@ -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 {SecretForm} from './secret-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 SecretForm> = {
27
+ title: 'Secrets/SecretForm',
28
+ component: SecretForm,
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 SecretForm>;
46
+
47
+ export const Playground: Story = {};
48
+
49
+ export const Edit: Story = {
50
+ args: {mode: 'edit', existingKey: 'API_TOKEN'},
51
+ };
52
+
53
+ export const ShortValueWarning: Story = {
54
+ play: async ({canvasElement}) => {
55
+ const canvas = within(canvasElement);
56
+ await userEvent.type(canvas.getByLabelText('Value'), 'abc');
57
+ },
58
+ };
@@ -0,0 +1,37 @@
1
+ import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
2
+ import {render, screen} from '@testing-library/react';
3
+ import {SecretForm} from './secret-form.js';
4
+
5
+ function renderEditForm() {
6
+ const queryClient = new QueryClient();
7
+ return render(
8
+ <QueryClientProvider client={queryClient}>
9
+ <SecretForm
10
+ workspaceId="11111111-1111-4111-8111-111111111111"
11
+ mode="edit"
12
+ existingKey="API_TOKEN"
13
+ onSaved={() => undefined}
14
+ onCancel={() => undefined}
15
+ />
16
+ </QueryClientProvider>,
17
+ );
18
+ }
19
+
20
+ describe('SecretForm write-only invariant', () => {
21
+ test('never prefills the value when editing an existing secret', () => {
22
+ renderEditForm();
23
+
24
+ const value = screen.getByLabelText('Value') as HTMLTextAreaElement;
25
+
26
+ expect(value.value).toBe('');
27
+ });
28
+
29
+ test('locks the name and keeps it uneditable when editing', () => {
30
+ renderEditForm();
31
+
32
+ const name = screen.getByLabelText('Name') as HTMLInputElement;
33
+
34
+ expect(name.value).toBe('API_TOKEN');
35
+ expect(name).toBeDisabled();
36
+ });
37
+ });
@@ -0,0 +1,170 @@
1
+ import {isShortSecretValue} from '@shipfox/api-secrets-dto';
2
+ import {Button, IconButton} 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 {useState} from 'react';
13
+ import {usePutSecretMutation} from '#hooks/api/secrets.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 SECRET_FORM_ID = 'secret-form';
19
+
20
+ // Client-side mirror of the server default SECRETS_SHORT_VALUE_WARN_LENGTH. The
21
+ // advisory is best-effort UX, so drift from a self-hosted override is cosmetic.
22
+ const SHORT_VALUE_THRESHOLD = 12;
23
+
24
+ export function SecretForm({
25
+ workspaceId,
26
+ mode,
27
+ existingKey,
28
+ reservedKeys = [],
29
+ onSaved,
30
+ onCancel,
31
+ }: {
32
+ workspaceId: string;
33
+ mode: 'create' | 'edit';
34
+ existingKey?: string | undefined;
35
+ reservedKeys?: readonly string[] | undefined;
36
+ onSaved: () => void;
37
+ onCancel: () => void;
38
+ }) {
39
+ const putSecret = usePutSecretMutation();
40
+ const [formError, setFormError] = useState<string | undefined>();
41
+ const [showValue, setShowValue] = useState(false);
42
+
43
+ const form = useForm({
44
+ defaultValues: {key: existingKey ?? '', value: ''},
45
+ onSubmit: async ({value}) => {
46
+ setFormError(undefined);
47
+ try {
48
+ await putSecret.mutateAsync({workspaceId, key: value.key, body: {value: value.value}});
49
+ onSaved();
50
+ } catch (error) {
51
+ const mapped = secretsErrorToFormError(error);
52
+ if (mapped.kind === 'field') {
53
+ form.setFieldMeta(mapped.field, (prev) => ({
54
+ ...prev,
55
+ errorMap: {...prev.errorMap, onServer: mapped.message},
56
+ }));
57
+ } else {
58
+ setFormError(mapped.message);
59
+ }
60
+ }
61
+ },
62
+ });
63
+
64
+ return (
65
+ <>
66
+ <FormBody>
67
+ <form
68
+ id={SECRET_FORM_ID}
69
+ className="flex w-full flex-col gap-16"
70
+ noValidate
71
+ onSubmit={(event) => {
72
+ event.preventDefault();
73
+ event.stopPropagation();
74
+ void form.handleSubmit();
75
+ }}
76
+ >
77
+ <form.Field
78
+ name="key"
79
+ validators={{
80
+ onBlur: ({value}) => validateNewStoreKey(value, {mode, reservedKeys, kind: 'secret'}),
81
+ onSubmit: ({value}) =>
82
+ validateNewStoreKey(value, {mode, reservedKeys, kind: 'secret'}),
83
+ }}
84
+ >
85
+ {(field) => (
86
+ <FormField
87
+ label="Name"
88
+ id="secret-key"
89
+ description={STORE_KEY_HELP}
90
+ error={fieldError(field)}
91
+ >
92
+ <FormFieldInput
93
+ className="font-code"
94
+ autoComplete="off"
95
+ spellCheck={false}
96
+ disabled={mode === 'edit'}
97
+ placeholder="MY_TOKEN"
98
+ value={field.state.value}
99
+ onChange={(event) => field.handleChange(event.target.value.toUpperCase())}
100
+ onBlur={field.handleBlur}
101
+ />
102
+ </FormField>
103
+ )}
104
+ </form.Field>
105
+
106
+ <form.Field
107
+ name="value"
108
+ validators={{
109
+ onBlur: ({value}) => (value.length > 0 ? undefined : 'A value is required.'),
110
+ onSubmit: ({value}) => (value.length > 0 ? undefined : 'A value is required.'),
111
+ }}
112
+ >
113
+ {(field) => (
114
+ <FormField label="Value" id="secret-value" error={fieldError(field)}>
115
+ <div className="relative">
116
+ <FormFieldTextarea
117
+ className={
118
+ showValue ? 'pr-32 font-code' : 'pr-32 font-code [-webkit-text-security:disc]'
119
+ }
120
+ autoComplete="off"
121
+ spellCheck={false}
122
+ rows={3}
123
+ placeholder={
124
+ mode === 'edit'
125
+ ? 'Enter a new value to replace the current one'
126
+ : 'Paste the secret value'
127
+ }
128
+ value={field.state.value}
129
+ onChange={(event) => field.handleChange(event.target.value)}
130
+ onBlur={field.handleBlur}
131
+ />
132
+ <IconButton
133
+ type="button"
134
+ variant="transparent"
135
+ size="xs"
136
+ className="absolute top-6 right-6"
137
+ icon={showValue ? 'eyeOffLine' : 'eyeLine'}
138
+ aria-label={showValue ? 'Hide value' : 'Show value'}
139
+ onClick={() => setShowValue((prev) => !prev)}
140
+ />
141
+ </div>
142
+ {isShortSecretValue(field.state.value, SHORT_VALUE_THRESHOLD) ? (
143
+ <Callout role="alert" type="warning">
144
+ <Text size="sm">
145
+ Very short secrets can match ordinary log text and redact unrelated output. If
146
+ this value is not sensitive, store it as a Variable instead.
147
+ </Text>
148
+ </Callout>
149
+ ) : null}
150
+ </FormField>
151
+ )}
152
+ </form.Field>
153
+ </form>
154
+ {formError ? (
155
+ <Callout role="alert" type="error">
156
+ <Text size="sm">{formError}</Text>
157
+ </Callout>
158
+ ) : null}
159
+ </FormBody>
160
+ <FormFooter>
161
+ <Button variant="secondary" type="button" onClick={onCancel}>
162
+ Cancel
163
+ </Button>
164
+ <Button type="submit" form={SECRET_FORM_ID} isLoading={putSecret.isPending}>
165
+ {mode === 'edit' ? 'Update secret' : 'Create secret'}
166
+ </Button>
167
+ </FormFooter>
168
+ </>
169
+ );
170
+ }