@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,231 @@
1
+ import type {SecretDto} from '@shipfox/api-secrets-dto';
2
+ import {QueryLoadError} from '@shipfox/client-ui';
3
+ import {Button, IconButton} from '@shipfox/react-ui/button';
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuTrigger,
9
+ } from '@shipfox/react-ui/dropdown-menu';
10
+ import {EmptyState} from '@shipfox/react-ui/empty-state';
11
+ import {Modal, ModalContent, ModalHeader, ModalTitle} from '@shipfox/react-ui/modal';
12
+ import {RelativeTime, RelativeTimeProvider} from '@shipfox/react-ui/relative-time';
13
+ import {
14
+ Table,
15
+ TableBody,
16
+ TableCell,
17
+ TableHead,
18
+ TableHeader,
19
+ TableRow,
20
+ } from '@shipfox/react-ui/table';
21
+ import {toast} from '@shipfox/react-ui/toast';
22
+ import {Code, Header, Text} from '@shipfox/react-ui/typography';
23
+ import {useMemo, useState} from 'react';
24
+ import {useDeleteSecretMutation, useSecretsQuery} from '#hooks/api/secrets.js';
25
+ import {copyKeyName} from './copy-key.js';
26
+ import {DeleteEntryDialog} from './delete-entry-dialog.js';
27
+ import {secretsErrorToFormError} from './form-errors.js';
28
+ import {SecretForm} from './secret-form.js';
29
+ import {StoreRowsSkeleton, StoreSurface} from './store-section-shell.js';
30
+
31
+ const SECRETS_DESCRIPTION =
32
+ 'Encrypted, write-only values for sensitive data like API keys, tokens, and passwords.';
33
+ const EMPTY_SECRETS_DESCRIPTION =
34
+ 'Create a secret to store sensitive values like API keys, tokens, and passwords.';
35
+
36
+ type FormState = {mode: 'create'} | {mode: 'edit'; key: string} | null;
37
+
38
+ export function WorkspaceSecretsSection({workspaceId}: {workspaceId: string}) {
39
+ const secretsQuery = useSecretsQuery(workspaceId);
40
+ const deleteSecret = useDeleteSecretMutation();
41
+ const [formState, setFormState] = useState<FormState>(null);
42
+ const [deleteKey, setDeleteKey] = useState<string | null>(null);
43
+ const [deleteError, setDeleteError] = useState<string | undefined>();
44
+
45
+ const secrets = useMemo(
46
+ () => [...(secretsQuery.data ?? [])].sort((a, b) => a.key.localeCompare(b.key)),
47
+ [secretsQuery.data],
48
+ );
49
+
50
+ function closeDelete() {
51
+ setDeleteKey(null);
52
+ setDeleteError(undefined);
53
+ }
54
+
55
+ return (
56
+ <RelativeTimeProvider>
57
+ <section className="flex flex-col gap-16" aria-label="Secrets">
58
+ <div className="flex items-start justify-between gap-16">
59
+ <div className="flex flex-col gap-4">
60
+ <Header variant="h3">Secrets</Header>
61
+ <Text size="sm" className="text-foreground-neutral-muted">
62
+ {SECRETS_DESCRIPTION}
63
+ </Text>
64
+ </div>
65
+ <Button size="sm" onClick={() => setFormState({mode: 'create'})}>
66
+ Create secret
67
+ </Button>
68
+ </div>
69
+
70
+ {secretsQuery.isPending ? <StoreRowsSkeleton label="Loading secrets" /> : null}
71
+
72
+ {secretsQuery.isError && secretsQuery.data === undefined ? (
73
+ <StoreSurface className="px-16">
74
+ <QueryLoadError query={secretsQuery} subject="secrets" />
75
+ </StoreSurface>
76
+ ) : null}
77
+
78
+ {secretsQuery.data !== undefined && secrets.length === 0 ? (
79
+ <StoreSurface className="px-16">
80
+ <EmptyState
81
+ icon="keyLine"
82
+ title="No secrets yet"
83
+ description={EMPTY_SECRETS_DESCRIPTION}
84
+ action={
85
+ <Button size="sm" onClick={() => setFormState({mode: 'create'})}>
86
+ Create secret
87
+ </Button>
88
+ }
89
+ />
90
+ </StoreSurface>
91
+ ) : null}
92
+
93
+ {secrets.length > 0 ? (
94
+ <StoreSurface>
95
+ <Table>
96
+ <TableHeader>
97
+ <TableRow>
98
+ <TableHead>Name</TableHead>
99
+ <TableHead>Value</TableHead>
100
+ <TableHead>Last edited</TableHead>
101
+ <TableHead className="w-40 text-right">
102
+ <span className="sr-only">Actions</span>
103
+ </TableHead>
104
+ </TableRow>
105
+ </TableHeader>
106
+ <TableBody>
107
+ {secrets.map((secret) => (
108
+ <SecretRow
109
+ key={secret.key}
110
+ secret={secret}
111
+ onEdit={() => setFormState({mode: 'edit', key: secret.key})}
112
+ onDelete={() => setDeleteKey(secret.key)}
113
+ />
114
+ ))}
115
+ </TableBody>
116
+ </Table>
117
+ </StoreSurface>
118
+ ) : null}
119
+ </section>
120
+
121
+ <Modal
122
+ open={formState !== null}
123
+ onOpenChange={(open) => {
124
+ if (!open) setFormState(null);
125
+ }}
126
+ >
127
+ <ModalContent>
128
+ <ModalHeader>
129
+ <ModalTitle>
130
+ {formState?.mode === 'edit' ? 'Update secret' : 'Create secret'}
131
+ </ModalTitle>
132
+ </ModalHeader>
133
+ {formState ? (
134
+ <SecretForm
135
+ workspaceId={workspaceId}
136
+ mode={formState.mode}
137
+ existingKey={formState.mode === 'edit' ? formState.key : undefined}
138
+ reservedKeys={secrets.map((secret) => secret.key)}
139
+ onSaved={() => {
140
+ const wasEdit = formState.mode === 'edit';
141
+ setFormState(null);
142
+ toast.success(wasEdit ? 'Secret updated' : 'Secret created');
143
+ }}
144
+ onCancel={() => setFormState(null)}
145
+ />
146
+ ) : null}
147
+ </ModalContent>
148
+ </Modal>
149
+
150
+ <DeleteEntryDialog
151
+ open={deleteKey !== null}
152
+ onOpenChange={(open) => {
153
+ if (!open) closeDelete();
154
+ }}
155
+ entryKey={deleteKey ?? ''}
156
+ isLoading={deleteSecret.isPending}
157
+ errorMessage={deleteError}
158
+ onConfirm={async () => {
159
+ if (deleteKey === null) return;
160
+ setDeleteError(undefined);
161
+ try {
162
+ await deleteSecret.mutateAsync({workspaceId, key: deleteKey});
163
+ toast.success('Secret deleted');
164
+ closeDelete();
165
+ } catch (error) {
166
+ setDeleteError(secretsErrorToFormError(error).message);
167
+ }
168
+ }}
169
+ />
170
+ </RelativeTimeProvider>
171
+ );
172
+ }
173
+
174
+ function SecretRow({
175
+ secret,
176
+ onEdit,
177
+ onDelete,
178
+ }: {
179
+ secret: SecretDto;
180
+ onEdit: () => void;
181
+ onDelete: () => void;
182
+ }) {
183
+ return (
184
+ <TableRow>
185
+ <TableCell>
186
+ <button
187
+ type="button"
188
+ className="inline-flex min-w-0 cursor-pointer rounded-4 border-none bg-transparent p-0 text-left text-foreground-neutral-base outline-none transition-colors hover:text-foreground-highlight-interactive focus-visible:shadow-border-interactive-with-active"
189
+ aria-label={`Copy secret name ${secret.key}`}
190
+ onClick={() => void copyKeyName(secret.key)}
191
+ >
192
+ <Code as="span" variant="paragraph" className="truncate">
193
+ {secret.key}
194
+ </Code>
195
+ </button>
196
+ </TableCell>
197
+ <TableCell>
198
+ <span
199
+ role="img"
200
+ aria-label="Value hidden"
201
+ className="font-code text-foreground-neutral-muted"
202
+ >
203
+ ••••••••
204
+ </span>
205
+ </TableCell>
206
+ <TableCell className="text-foreground-neutral-muted">
207
+ <RelativeTime value={secret.updated_at} />
208
+ </TableCell>
209
+ <TableCell className="text-right">
210
+ <DropdownMenu>
211
+ <DropdownMenuTrigger asChild>
212
+ <IconButton
213
+ size="sm"
214
+ variant="transparent"
215
+ icon="more2Line"
216
+ aria-label={`Actions for ${secret.key}`}
217
+ />
218
+ </DropdownMenuTrigger>
219
+ <DropdownMenuContent align="end">
220
+ <DropdownMenuItem icon="editLine" onSelect={onEdit}>
221
+ Edit value
222
+ </DropdownMenuItem>
223
+ <DropdownMenuItem icon="deleteBinLine" onSelect={onDelete}>
224
+ Delete
225
+ </DropdownMenuItem>
226
+ </DropdownMenuContent>
227
+ </DropdownMenu>
228
+ </TableCell>
229
+ </TableRow>
230
+ );
231
+ }
@@ -0,0 +1,121 @@
1
+ import type {VariableListItemDto} 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 {WorkspaceVariablesSection} from './workspace-variables-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 VARIABLES: VariableListItemDto[] = [
29
+ {
30
+ key: 'LOG_LEVEL',
31
+ value: 'debug',
32
+ value_truncated: false,
33
+ project_id: null,
34
+ created_at: '2026-05-01T10:00:00.000Z',
35
+ updated_at: '2026-06-30T12:00:00.000Z',
36
+ last_edited_by: EDITOR_ID,
37
+ },
38
+ {
39
+ key: 'FEATURE_FLAG',
40
+ value: '',
41
+ value_truncated: false,
42
+ project_id: null,
43
+ created_at: '2026-04-01T10:00:00.000Z',
44
+ updated_at: '2026-06-25T12:00:00.000Z',
45
+ last_edited_by: EDITOR_ID,
46
+ },
47
+ {
48
+ key: 'TLS_CERT',
49
+ value: '-----BEGIN CERTIFICATE-----',
50
+ value_truncated: true,
51
+ project_id: null,
52
+ created_at: '2026-03-15T10:00:00.000Z',
53
+ updated_at: '2026-06-22T12:00:00.000Z',
54
+ last_edited_by: EDITOR_ID,
55
+ },
56
+ {
57
+ key: 'REGION',
58
+ value: 'eu-west-1',
59
+ value_truncated: false,
60
+ project_id: null,
61
+ created_at: '2026-03-01T10:00:00.000Z',
62
+ updated_at: '2026-06-20T12:00:00.000Z',
63
+ last_edited_by: null,
64
+ },
65
+ ];
66
+
67
+ type Scenario = 'loaded' | 'empty';
68
+
69
+ function fetchForScenario(scenario: Scenario): typeof fetch {
70
+ return (() => {
71
+ const variables = scenario === 'empty' ? [] : VARIABLES;
72
+ return Promise.resolve(
73
+ new Response(JSON.stringify({variables, next_cursor: null}), {
74
+ status: 200,
75
+ headers: {'content-type': 'application/json'},
76
+ }),
77
+ );
78
+ }) as unknown as typeof fetch;
79
+ }
80
+
81
+ function SectionStory({scenario}: {scenario: Scenario}) {
82
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl: fetchForScenario(scenario)});
83
+ const queryClient = useMemo(
84
+ () => new QueryClient({defaultOptions: {queries: {retry: false}}}),
85
+ [],
86
+ );
87
+ return (
88
+ <QueryClientProvider client={queryClient}>
89
+ <div className="w-[720px] p-24">
90
+ <WorkspaceVariablesSection workspaceId={WORKSPACE_ID} />
91
+ </div>
92
+ <Toaster />
93
+ </QueryClientProvider>
94
+ );
95
+ }
96
+
97
+ const meta: Meta<typeof SectionStory> = {
98
+ title: 'Secrets/WorkspaceVariablesSection',
99
+ component: SectionStory,
100
+ decorators: [withFrozenClock],
101
+ };
102
+ export default meta;
103
+
104
+ type Story = StoryObj<typeof SectionStory>;
105
+
106
+ export const Loaded: Story = {
107
+ args: {scenario: 'loaded'},
108
+ play: async ({canvasElement}) => {
109
+ const canvas = within(canvasElement);
110
+
111
+ await canvas.findByText('LOG_LEVEL');
112
+ await canvas.findByRole('button', {name: 'Copy variable name LOG_LEVEL'});
113
+ },
114
+ };
115
+
116
+ export const Empty: Story = {
117
+ args: {scenario: 'empty'},
118
+ play: async ({canvasElement}) => {
119
+ await within(canvasElement).findByText('No variables yet');
120
+ },
121
+ };
@@ -0,0 +1,238 @@
1
+ import type {VariableListItemDto} from '@shipfox/api-secrets-dto';
2
+ import {QueryLoadError} from '@shipfox/client-ui';
3
+ import {Button, IconButton} from '@shipfox/react-ui/button';
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuTrigger,
9
+ } from '@shipfox/react-ui/dropdown-menu';
10
+ import {EmptyState} from '@shipfox/react-ui/empty-state';
11
+ import {Modal, ModalContent, ModalHeader, ModalTitle} from '@shipfox/react-ui/modal';
12
+ import {RelativeTime, RelativeTimeProvider} from '@shipfox/react-ui/relative-time';
13
+ import {
14
+ Table,
15
+ TableBody,
16
+ TableCell,
17
+ TableHead,
18
+ TableHeader,
19
+ TableRow,
20
+ } from '@shipfox/react-ui/table';
21
+ import {toast} from '@shipfox/react-ui/toast';
22
+ import {Code, Header, Text} from '@shipfox/react-ui/typography';
23
+ import {useMemo, useState} from 'react';
24
+ import {useDeleteVariableMutation, useVariablesQuery} from '#hooks/api/variables.js';
25
+ import {copyKeyName} from './copy-key.js';
26
+ import {DeleteEntryDialog} from './delete-entry-dialog.js';
27
+ import {secretsErrorToFormError} from './form-errors.js';
28
+ import {StoreRowsSkeleton, StoreSurface} from './store-section-shell.js';
29
+ import {VariableForm} from './variable-form.js';
30
+
31
+ const VARIABLES_DESCRIPTION =
32
+ 'Plaintext configuration values for non-sensitive data like regions, flags, and log levels.';
33
+ const EMPTY_VARIABLES_DESCRIPTION =
34
+ 'Create a variable to store non-sensitive configuration like regions, flags, and log levels.';
35
+
36
+ type FormState = {mode: 'create'} | {mode: 'edit'; variable: VariableListItemDto} | null;
37
+
38
+ export function WorkspaceVariablesSection({workspaceId}: {workspaceId: string}) {
39
+ const variablesQuery = useVariablesQuery(workspaceId);
40
+ const deleteVariable = useDeleteVariableMutation();
41
+ const [formState, setFormState] = useState<FormState>(null);
42
+ const [deleteKey, setDeleteKey] = useState<string | null>(null);
43
+ const [deleteError, setDeleteError] = useState<string | undefined>();
44
+
45
+ const variables = useMemo(
46
+ () => [...(variablesQuery.data ?? [])].sort((a, b) => a.key.localeCompare(b.key)),
47
+ [variablesQuery.data],
48
+ );
49
+
50
+ function closeDelete() {
51
+ setDeleteKey(null);
52
+ setDeleteError(undefined);
53
+ }
54
+
55
+ return (
56
+ <RelativeTimeProvider>
57
+ <section className="flex flex-col gap-16" aria-label="Variables">
58
+ <div className="flex items-start justify-between gap-16">
59
+ <div className="flex flex-col gap-4">
60
+ <Header variant="h3">Variables</Header>
61
+ <Text size="sm" className="text-foreground-neutral-muted">
62
+ {VARIABLES_DESCRIPTION}
63
+ </Text>
64
+ </div>
65
+ <Button size="sm" onClick={() => setFormState({mode: 'create'})}>
66
+ Create variable
67
+ </Button>
68
+ </div>
69
+
70
+ {variablesQuery.isPending ? <StoreRowsSkeleton label="Loading variables" /> : null}
71
+
72
+ {variablesQuery.isError && variablesQuery.data === undefined ? (
73
+ <StoreSurface className="px-16">
74
+ <QueryLoadError query={variablesQuery} subject="variables" />
75
+ </StoreSurface>
76
+ ) : null}
77
+
78
+ {variablesQuery.data !== undefined && variables.length === 0 ? (
79
+ <StoreSurface className="px-16">
80
+ <EmptyState
81
+ icon="bracesLine"
82
+ title="No variables yet"
83
+ description={EMPTY_VARIABLES_DESCRIPTION}
84
+ action={
85
+ <Button size="sm" onClick={() => setFormState({mode: 'create'})}>
86
+ Create variable
87
+ </Button>
88
+ }
89
+ />
90
+ </StoreSurface>
91
+ ) : null}
92
+
93
+ {variables.length > 0 ? (
94
+ <StoreSurface>
95
+ <Table>
96
+ <TableHeader>
97
+ <TableRow>
98
+ <TableHead>Name</TableHead>
99
+ <TableHead>Value</TableHead>
100
+ <TableHead>Last edited</TableHead>
101
+ <TableHead className="w-40 text-right">
102
+ <span className="sr-only">Actions</span>
103
+ </TableHead>
104
+ </TableRow>
105
+ </TableHeader>
106
+ <TableBody>
107
+ {variables.map((variable) => (
108
+ <VariableRow
109
+ key={variable.key}
110
+ variable={variable}
111
+ onEdit={() => setFormState({mode: 'edit', variable})}
112
+ onDelete={() => setDeleteKey(variable.key)}
113
+ />
114
+ ))}
115
+ </TableBody>
116
+ </Table>
117
+ </StoreSurface>
118
+ ) : null}
119
+ </section>
120
+
121
+ <Modal
122
+ open={formState !== null}
123
+ onOpenChange={(open) => {
124
+ if (!open) setFormState(null);
125
+ }}
126
+ >
127
+ <ModalContent>
128
+ <ModalHeader>
129
+ <ModalTitle>
130
+ {formState?.mode === 'edit' ? 'Update variable' : 'Create variable'}
131
+ </ModalTitle>
132
+ </ModalHeader>
133
+ {formState ? (
134
+ <VariableForm
135
+ workspaceId={workspaceId}
136
+ mode={formState.mode}
137
+ existingKey={formState.mode === 'edit' ? formState.variable.key : undefined}
138
+ existingValue={formState.mode === 'edit' ? formState.variable.value : undefined}
139
+ existingValueTruncated={
140
+ formState.mode === 'edit' ? formState.variable.value_truncated : undefined
141
+ }
142
+ reservedKeys={variables.map((variable) => variable.key)}
143
+ onSaved={() => {
144
+ const wasEdit = formState.mode === 'edit';
145
+ setFormState(null);
146
+ toast.success(wasEdit ? 'Variable updated' : 'Variable created');
147
+ }}
148
+ onCancel={() => setFormState(null)}
149
+ />
150
+ ) : null}
151
+ </ModalContent>
152
+ </Modal>
153
+
154
+ <DeleteEntryDialog
155
+ open={deleteKey !== null}
156
+ onOpenChange={(open) => {
157
+ if (!open) closeDelete();
158
+ }}
159
+ entryKey={deleteKey ?? ''}
160
+ isLoading={deleteVariable.isPending}
161
+ errorMessage={deleteError}
162
+ onConfirm={async () => {
163
+ if (deleteKey === null) return;
164
+ setDeleteError(undefined);
165
+ try {
166
+ await deleteVariable.mutateAsync({workspaceId, key: deleteKey});
167
+ toast.success('Variable deleted');
168
+ closeDelete();
169
+ } catch (error) {
170
+ setDeleteError(secretsErrorToFormError(error).message);
171
+ }
172
+ }}
173
+ />
174
+ </RelativeTimeProvider>
175
+ );
176
+ }
177
+
178
+ function VariableRow({
179
+ variable,
180
+ onEdit,
181
+ onDelete,
182
+ }: {
183
+ variable: VariableListItemDto;
184
+ onEdit: () => void;
185
+ onDelete: () => void;
186
+ }) {
187
+ return (
188
+ <TableRow>
189
+ <TableCell>
190
+ <button
191
+ type="button"
192
+ className="inline-flex min-w-0 cursor-pointer rounded-4 border-none bg-transparent p-0 text-left text-foreground-neutral-base outline-none transition-colors hover:text-foreground-highlight-interactive focus-visible:shadow-border-interactive-with-active"
193
+ aria-label={`Copy variable name ${variable.key}`}
194
+ onClick={() => void copyKeyName(variable.key)}
195
+ >
196
+ <Code as="span" variant="paragraph" className="truncate">
197
+ {variable.key}
198
+ </Code>
199
+ </button>
200
+ </TableCell>
201
+ <TableCell>
202
+ <span
203
+ title={variable.value}
204
+ className="block max-w-[280px] truncate font-code text-foreground-neutral-base"
205
+ >
206
+ {variable.value === '' ? (
207
+ <span className="text-foreground-neutral-muted">(empty)</span>
208
+ ) : (
209
+ variable.value
210
+ )}
211
+ </span>
212
+ </TableCell>
213
+ <TableCell className="text-foreground-neutral-muted">
214
+ <RelativeTime value={variable.updated_at} />
215
+ </TableCell>
216
+ <TableCell className="text-right">
217
+ <DropdownMenu>
218
+ <DropdownMenuTrigger asChild>
219
+ <IconButton
220
+ size="sm"
221
+ variant="transparent"
222
+ icon="more2Line"
223
+ aria-label={`Actions for ${variable.key}`}
224
+ />
225
+ </DropdownMenuTrigger>
226
+ <DropdownMenuContent align="end">
227
+ <DropdownMenuItem icon="editLine" onSelect={onEdit}>
228
+ Edit value
229
+ </DropdownMenuItem>
230
+ <DropdownMenuItem icon="deleteBinLine" onSelect={onDelete}>
231
+ Delete
232
+ </DropdownMenuItem>
233
+ </DropdownMenuContent>
234
+ </DropdownMenu>
235
+ </TableCell>
236
+ </TableRow>
237
+ );
238
+ }