From cd2c631c4ef3951481877d7a374291bf1bd3861a Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 2 Jul 2026 12:38:04 -0400 Subject: [PATCH 1/2] feat(secrets): version-adaptive .env editor in the application editor Secret-bearing .env files (duck-typed by basename, at any depth; template files like .env.example keep the plain text editor) now open in a managed secrets panel instead of raw Monaco, so values aren't flashed on screen or clobbered by accident. The panel adapts to the connected Harper by the shape of the get_component_file response, not a version gate: - Plaintext (pre-5.2): keys/values parsed client-side with a per-row click-to-reveal; add/edit/delete rewrite the file through the existing set_component_file, merge-preserving via a browser-safe port of core's utility/envFile.ts (dotenv-16 semantics, Harper's test suite ported). An "Edit as text" escape hatch keeps the raw editor available. - Protected (>= 5.2, harper#1527): key names only, nothing revealable, no raw editing (saving the masked rendering would destroy the real values); writes go through the new set_env_value / delete_env_value operations. Includes the shared SecretsManager / SecretModals components (masked table, click-to-reveal, add/edit dialogs) that the stacked cluster-secrets PR also builds on, with a fix for a latent react-hook-form subscription bug (a pasted value left Save permanently disabled because isValid was never read while the form was pristine). Co-Authored-By: Claude Fable 5 --- .../ApplicationsSidebar/FileTypeIcon.tsx | 9 +- .../ApplicationsSidebar/ItemTitle.tsx | 2 +- .../applications/components/ContentViewer.tsx | 11 + .../EnvEditorView/EnvEditorView.test.tsx | 281 +++++++++++++++ .../components/EnvEditorView/index.tsx | 185 ++++++++++ .../instance/secrets/SecretModals.tsx | 322 ++++++++++++++++++ .../instance/secrets/SecretsManager.tsx | 202 +++++++++++ .../api/instance/applications/envValues.ts | 73 ++++ .../instance/applications/getComponentFile.ts | 7 + src/lib/env/envFile.test.ts | 264 ++++++++++++++ src/lib/env/envFile.ts | 233 +++++++++++++ 11 files changed, 1587 insertions(+), 2 deletions(-) create mode 100644 src/features/instance/applications/components/EnvEditorView/EnvEditorView.test.tsx create mode 100644 src/features/instance/applications/components/EnvEditorView/index.tsx create mode 100644 src/features/instance/secrets/SecretModals.tsx create mode 100644 src/features/instance/secrets/SecretsManager.tsx create mode 100644 src/integrations/api/instance/applications/envValues.ts create mode 100644 src/lib/env/envFile.test.ts create mode 100644 src/lib/env/envFile.ts diff --git a/src/features/instance/applications/components/ApplicationsSidebar/FileTypeIcon.tsx b/src/features/instance/applications/components/ApplicationsSidebar/FileTypeIcon.tsx index b6101d708..9422a6c58 100644 --- a/src/features/instance/applications/components/ApplicationsSidebar/FileTypeIcon.tsx +++ b/src/features/instance/applications/components/ApplicationsSidebar/FileTypeIcon.tsx @@ -1,15 +1,22 @@ +import { isEnvFile } from '@/lib/env/envFile'; import { CodeIcon, FileCode2Icon, FileIcon, FileImageIcon, FileJsonIcon, + FileKeyIcon, FileTypeIcon as LucideFileTypeIcon, } from 'lucide-react'; -export function FileTypeIcon({ extension }: { readonly extension: string | null }) { +export function FileTypeIcon({ extension, filename }: { readonly extension: string | null; filename?: string }) { const iconClassName = 'w-4 h-4 mr-2 shrink-0'; + // `.env` files are matched by full name, not extension (`.env.local`'s "extension" is `local`). + if (isEnvFile(filename)) { + return ; + } + switch (extension) { case 'js': case 'jsx': diff --git a/src/features/instance/applications/components/ApplicationsSidebar/ItemTitle.tsx b/src/features/instance/applications/components/ApplicationsSidebar/ItemTitle.tsx index 4b77cfba9..3d74e8bcc 100644 --- a/src/features/instance/applications/components/ApplicationsSidebar/ItemTitle.tsx +++ b/src/features/instance/applications/components/ApplicationsSidebar/ItemTitle.tsx @@ -28,7 +28,7 @@ export function ItemTitle({ title, item, context }: { pkg={!!item.data?.package || item.data.path === importedApplications} /> ) - : } + : } {title}{content ? '*' : ''} {item.data?.package && } diff --git a/src/features/instance/applications/components/ContentViewer.tsx b/src/features/instance/applications/components/ContentViewer.tsx index 27f0e4de3..b875da681 100644 --- a/src/features/instance/applications/components/ContentViewer.tsx +++ b/src/features/instance/applications/components/ContentViewer.tsx @@ -3,6 +3,7 @@ import { isDirectory } from '@/features/instance/applications/context/isDirector import { useEditorView } from '@/features/instance/applications/hooks/useEditorView'; import { useReadMeUrlTransformer } from '@/features/instance/applications/lib/readMeUrlTransform'; import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'; +import { isProtectedEnvFile } from '@/lib/env/envFile'; import { isBinaryFile } from '@/lib/string/binaryFileType'; import { getMediaFileType, MediaFileType } from '@/lib/string/mediaFileType'; import { CopyIcon, FileArchive } from 'lucide-react'; @@ -11,6 +12,7 @@ import Markdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { newApplication } from './ApplicationsSidebar/specialItems'; import { DirectoryPlaceholder } from './DirectoryPlaceholder'; +import { EnvEditorView } from './EnvEditorView'; import { NewApplication } from './NewApplication'; import { TextEditorView } from './TextEditorView'; import './directoryReadMe.css'; @@ -56,6 +58,15 @@ export function ContentViewer() { return ; } + // Secret-bearing `.env` files get a managed secrets panel instead of the raw + // text editor, so values aren't flashed on screen or clobbered by accident. + // Template files (`.env.example` etc.) hold placeholders, not secrets, and + // fall through to the text editor. Keyed by path so per-file state (selection, + // raw-editor toggle) resets when switching files. + if (isProtectedEnvFile(openedEntry?.name)) { + return ; + } + return ; } diff --git a/src/features/instance/applications/components/EnvEditorView/EnvEditorView.test.tsx b/src/features/instance/applications/components/EnvEditorView/EnvEditorView.test.tsx new file mode 100644 index 000000000..1bc7a5b8d --- /dev/null +++ b/src/features/instance/applications/components/EnvEditorView/EnvEditorView.test.tsx @@ -0,0 +1,281 @@ +/** + * @vitest-environment jsdom + */ +import type { FileEntry } from '@/features/instance/applications/context/fileEntry'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { AxiosInstance } from 'axios'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { EnvEditorView } from './index'; + +/* + The view is exercised against a mocked per-instance operations client, so both Harper behaviors + are covered: older versions returning the raw `.env` text (plaintext mode: click-to-reveal, + whole-file writes) and >= 5.2 returning `protected: true` (protected mode: key-level operations, + nothing revealable). + */ + +const openedEntry: FileEntry = { + name: '.env', + path: 'myapp/.env', + project: 'myapp', +}; + +const post = vi.fn(); +const instanceClient = { post } as unknown as AxiosInstance; + +vi.mock('@/config/useInstanceClient', () => ({ + useInstanceClientIdParams: () => ({ instanceClient, entityId: 'test-instance', entityType: 'instance' }), +})); + +vi.mock('@/features/instance/applications/hooks/useEditorView', () => ({ + useEditorView: () => ({ openedEntry }), +})); + +// Avoids the router dependency of the real sessionStorage-backed hook; the raw-buffer +// interplay it powers isn't under test here. +const setContent = vi.fn(); +vi.mock('@/features/instance/applications/context/editorFileContent', () => ({ + useEditorFileContent: () => ({ content: undefined, setContent }), +})); + +vi.mock('@/hooks/usePermissions', () => ({ + useInstanceBrowseManagePermission: () => true, +})); + +// The raw-text escape hatch mounts Monaco; a marker div is enough to assert the handoff. +vi.mock('@/features/instance/applications/components/TextEditorView', () => ({ + TextEditorView: () =>
, +})); + +// Radix dialogs rely on a couple of DOM APIs jsdom doesn't implement. +beforeAll(() => { + Element.prototype.hasPointerCapture ??= () => false; + Element.prototype.setPointerCapture ??= () => undefined; + Element.prototype.releasePointerCapture ??= () => undefined; + Element.prototype.scrollIntoView ??= () => undefined; + if (typeof window.PointerEvent === 'undefined') { + window.PointerEvent = class extends MouseEvent {} as typeof PointerEvent; + } +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const PLAINTEXT_FILE = '# note\nAPI_KEY=secret123\nDB_URL=postgres://x\n'; + +/** Routes mocked operation calls; returns the calls recorded for later assertions. */ +function mockOperations({ fileResponse }: { fileResponse: Record }) { + post.mockImplementation((_url: string, body: { operation: string; key?: string }) => { + switch (body.operation) { + case 'get_component_file': + return Promise.resolve({ data: fileResponse }); + case 'set_component_file': + return Promise.resolve({ data: { message: 'ok' } }); + case 'set_env_value': + return Promise.resolve({ data: { message: 'ok', keys: ['API_KEY', 'DB_URL', body.key] } }); + case 'delete_env_value': + return Promise.resolve({ data: { message: 'ok', keys: ['DB_URL'] } }); + default: + return Promise.reject(new Error(`Unexpected operation ${body.operation}`)); + } + }); +} + +function renderView() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +function operationCalls(operation: string) { + return post.mock.calls.filter(([, body]) => body.operation === operation).map(([, body]) => body); +} + +describe('EnvEditorView (plaintext mode — Harper < 5.2 returns raw text)', () => { + function mockPlaintext() { + mockOperations({ + fileResponse: { message: PLAINTEXT_FILE, size: PLAINTEXT_FILE.length, mtime: 'x', birthtime: 'x' }, + }); + } + + it('lists keys with masked values and reveals one only on demand', async () => { + mockPlaintext(); + renderView(); + + expect(await screen.findByText('API_KEY')).toBeTruthy(); + expect(screen.getByText('DB_URL')).toBeTruthy(); + // No value is in the document until deliberately revealed. + expect(screen.queryByText('secret123')).toBeNull(); + + fireEvent.click(screen.getAllByTitle('Reveal value')[0]); + expect(screen.getByText('secret123')).toBeTruthy(); + + // And it can be hidden again. + fireEvent.click(screen.getByTitle('Hide value')); + expect(screen.queryByText('secret123')).toBeNull(); + }); + + it('adds a secret by rewriting the whole file, preserving comments and other keys', async () => { + mockPlaintext(); + renderView(); + await screen.findByText('API_KEY'); + + fireEvent.click(screen.getByRole('button', { name: /add/i })); + fireEvent.change(await screen.findByLabelText('Key'), { target: { value: 'NEW_KEY' } }); + fireEvent.change(screen.getByLabelText('Value'), { target: { value: 'two words' } }); + + const submit = screen.getByRole('button', { name: /add secret/i }); + await waitFor(() => expect(submit.hasAttribute('disabled')).toBe(false)); + fireEvent.click(submit); + + await waitFor(() => expect(operationCalls('set_component_file')).toHaveLength(1)); + expect(operationCalls('set_component_file')[0]).toMatchObject({ + project: 'myapp', + file: '.env', + payload: `# note\nAPI_KEY=secret123\nDB_URL=postgres://x\nNEW_KEY='two words'\n`, + }); + }); + + it('edits a secret in place after click-to-reveal pre-fills the current value', async () => { + mockPlaintext(); + renderView(); + + fireEvent.click(await screen.findByText('API_KEY')); + const revealButton = await screen.findByRole('button', { name: /reveal current value/i }); + fireEvent.click(revealButton); + + const textarea = screen.getByLabelText('Value') as HTMLTextAreaElement; + expect(textarea.value).toBe('secret123'); + fireEvent.change(textarea, { target: { value: 'rotated' } }); + + const save = screen.getByRole('button', { name: /save/i }); + await waitFor(() => expect(save.hasAttribute('disabled')).toBe(false)); + fireEvent.click(save); + + await waitFor(() => expect(operationCalls('set_component_file')).toHaveLength(1)); + expect(operationCalls('set_component_file')[0]).toMatchObject({ + payload: '# note\nAPI_KEY=rotated\nDB_URL=postgres://x\n', + }); + }); + + it('deletes a secret through the edit dialog with a two-click confirm', async () => { + mockPlaintext(); + renderView(); + + fireEvent.click(await screen.findByText('DB_URL')); + const deleteButton = await screen.findByRole('button', { name: /delete/i }); + fireEvent.click(deleteButton); + // First click arms the confirmation; nothing is written yet. + expect(operationCalls('set_component_file')).toHaveLength(0); + fireEvent.click(screen.getByRole('button', { name: /confirm delete/i })); + + await waitFor(() => expect(operationCalls('set_component_file')).toHaveLength(1)); + expect(operationCalls('set_component_file')[0]).toMatchObject({ + payload: '# note\nAPI_KEY=secret123\n', + }); + }); + + it('offers the raw text editor as an escape hatch', async () => { + mockPlaintext(); + renderView(); + await screen.findByText('API_KEY'); + + fireEvent.click(screen.getByRole('button', { name: /edit as text/i })); + expect(screen.getByTestId('raw-text-editor')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: /secrets view/i })); + expect(await screen.findByText('API_KEY')).toBeTruthy(); + }); +}); + +describe('EnvEditorView (protected mode — Harper >= 5.2 masks values)', () => { + const MASKED = 'API_KEY=********\nDB_URL=********\n'; + + function mockProtected() { + mockOperations({ + fileResponse: { + protected: true, + keys: ['API_KEY', 'DB_URL'], + message: MASKED, + size: MASKED.length, + mtime: 'x', + birthtime: 'x', + }, + }); + } + + it('lists key names with nothing revealable and no raw-text escape hatch', async () => { + mockProtected(); + renderView(); + + expect(await screen.findByText('API_KEY')).toBeTruthy(); + expect(screen.getByText('DB_URL')).toBeTruthy(); + expect(screen.queryByTitle('Reveal value')).toBeNull(); + expect(screen.queryByRole('button', { name: /edit as text/i })).toBeNull(); + }); + + it('adds a secret through set_env_value and shows the returned key list', async () => { + mockProtected(); + renderView(); + await screen.findByText('API_KEY'); + + fireEvent.click(screen.getByRole('button', { name: /add/i })); + fireEvent.change(await screen.findByLabelText('Key'), { target: { value: 'NEW_KEY' } }); + fireEvent.change(screen.getByLabelText('Value'), { target: { value: 's3cret' } }); + + const submit = screen.getByRole('button', { name: /add secret/i }); + await waitFor(() => expect(submit.hasAttribute('disabled')).toBe(false)); + fireEvent.click(submit); + + await waitFor(() => expect(operationCalls('set_env_value')).toHaveLength(1)); + expect(operationCalls('set_env_value')[0]).toMatchObject({ + project: 'myapp', + file: '.env', + key: 'NEW_KEY', + value: 's3cret', + replicated: false, + }); + // The key list from the operation response lands in the table without a refetch. + expect(await screen.findByText('NEW_KEY')).toBeTruthy(); + expect(operationCalls('get_component_file')).toHaveLength(1); + // The whole-file write path is never used against a protected file. + expect(operationCalls('set_component_file')).toHaveLength(0); + }); + + it('replaces a value through set_env_value without offering a reveal', async () => { + mockProtected(); + renderView(); + + fireEvent.click(await screen.findByText('API_KEY')); + await screen.findByRole('button', { name: /save/i }); + expect(screen.queryByRole('button', { name: /reveal current value/i })).toBeNull(); + + fireEvent.change(screen.getByLabelText('New value'), { target: { value: 'rotated' } }); + const save = screen.getByRole('button', { name: /save/i }); + await waitFor(() => expect(save.hasAttribute('disabled')).toBe(false)); + fireEvent.click(save); + + await waitFor(() => expect(operationCalls('set_env_value')).toHaveLength(1)); + expect(operationCalls('set_env_value')[0]).toMatchObject({ key: 'API_KEY', value: 'rotated' }); + }); + + it('deletes a secret through delete_env_value and drops it from the table', async () => { + mockProtected(); + renderView(); + + fireEvent.click(await screen.findByText('API_KEY')); + const deleteButton = await screen.findByRole('button', { name: /delete/i }); + fireEvent.click(deleteButton); + fireEvent.click(screen.getByRole('button', { name: /confirm delete/i })); + + await waitFor(() => expect(operationCalls('delete_env_value')).toHaveLength(1)); + expect(operationCalls('delete_env_value')[0]).toMatchObject({ key: 'API_KEY', replicated: false }); + await waitFor(() => expect(screen.queryByText('API_KEY')).toBeNull()); + }); +}); diff --git a/src/features/instance/applications/components/EnvEditorView/index.tsx b/src/features/instance/applications/components/EnvEditorView/index.tsx new file mode 100644 index 000000000..1b04f66db --- /dev/null +++ b/src/features/instance/applications/components/EnvEditorView/index.tsx @@ -0,0 +1,185 @@ +/** + * A secrets-style editor for an application's `.env` files, replacing the raw text editor so + * values aren't flashed on screen (or clobbered) by accident. It adapts to what the connected + * Harper can do, duck-typed off the `get_component_file` response rather than a version check: + * + * - Older Harper returns the raw file text → keys and values are parsed client-side, values are + * masked behind a deliberate click-to-reveal, and every add/edit/delete rewrites the whole + * file through `set_component_file` (merge-preserving, comments intact). An "Edit as text" + * escape hatch keeps the raw Monaco editor available. + * + * - Harper >= 5.2 protects `.env` files (`protected: true`, masked message, key names only) → + * values can never be read back, so there is no reveal and no raw editing (saving the masked + * rendering would destroy the real values); adds/edits/deletes go through the key-level + * `set_env_value` / `delete_env_value` operations instead. + * + * Template files (`.env.example` etc.) never reach this view — they aren't secret and keep the + * plain text editor (see ContentViewer). + */ +import { Button } from '@/components/ui/button'; +import { useInstanceClientIdParams } from '@/config/useInstanceClient'; +import { useEditorFileContent } from '@/features/instance/applications/context/editorFileContent'; +import { useEditorView } from '@/features/instance/applications/hooks/useEditorView'; +import { SecretRow, SecretsManager } from '@/features/instance/secrets/SecretsManager'; +import { useInstanceBrowseManagePermission } from '@/hooks/usePermissions'; +import { useDeleteEnvValue, useSetEnvValue } from '@/integrations/api/instance/applications/envValues'; +import { + getComponentFileQueryKey, + getComponentFileQueryOptions, + GetComponentFileResponse, +} from '@/integrations/api/instance/applications/getComponentFile'; +import { useSetComponentFile } from '@/integrations/api/instance/applications/setComponentFile'; +import { parseEnv, removeEnvKeys, renderMaskedEnv, upsertEnvValues } from '@/lib/env/envFile'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { CodeIcon, ShieldCheckIcon, Table2Icon } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; +import { TextEditorView } from '../TextEditorView'; + +export function EnvEditorView() { + const instanceParams = useInstanceClientIdParams(); + const queryClient = useQueryClient(); + const { openedEntry } = useEditorView(); + const canManageBrowseInstance = useInstanceBrowseManagePermission(); + + const path = openedEntry?.path ?? ''; + const project = openedEntry?.project ?? ''; + const file = path.split('/').slice(1).join('/'); + const fileQueryKey = getComponentFileQueryKey({ file, project, ...instanceParams }); + const { data, isFetching, refetch } = useQuery( + getComponentFileQueryOptions({ file, project, ...instanceParams }), + ); + + // Unsaved edits from the raw text view. The secrets table is a GUI over the same buffer the + // text editor shows, so it parses (and writes back) the dirty content when there is any. + const { content: updatedFileContent, setContent } = useEditorFileContent( + !!openedEntry && !openedEntry.package && path, + ); + + const isProtected = data?.protected === true; + const rawText = updatedFileContent ?? data?.message; + + const rows = useMemo(() => { + if (!data) { + return []; + } + if (isProtected) { + return (data.keys ?? []).map((name) => ({ name })); + } + return Object.entries(parseEnv(rawText ?? '')).map(([name, value]) => ({ name, value })); + }, [data, isProtected, rawText]); + + const canManage = !!openedEntry && !openedEntry.package && canManageBrowseInstance; + + /* + Persistence, per mode. Plaintext-mode writes go through the ordinary whole-file save and then + sync the query cache (the EditorViewProvider derives the opened contents from it). Protected- + mode writes go through the key-level operations, whose responses carry the resulting key list. + */ + const { mutateAsync: writeComponentFile } = useSetComponentFile(); + const { mutateAsync: setEnvValueAsync } = useSetEnvValue(); + const { mutateAsync: deleteEnvValueAsync } = useDeleteEnvValue(); + + const writeWholeFile = useCallback(async (next: string) => { + await writeComponentFile({ ...instanceParams, project, file, payload: next }); + queryClient.setQueryData( + fileQueryKey, + (old) => old && { ...old, message: next }, + ); + setContent(undefined); // the raw buffer (dirty edits included) is now saved + }, [writeComponentFile, instanceParams, project, file, queryClient, fileQueryKey, setContent]); + + const applyProtectedKeys = useCallback((keys: string[]) => { + queryClient.setQueryData( + fileQueryKey, + (old) => old && { ...old, keys, message: renderMaskedEnv(keys) }, + ); + }, [queryClient, fileQueryKey]); + + const onSet = useCallback(async (key: string, value: string) => { + if (isProtected) { + const response = await setEnvValueAsync({ ...instanceParams, project, file, key, value }); + applyProtectedKeys(response.keys); + } else { + await writeWholeFile(upsertEnvValues(rawText ?? '', { [key]: value })); + } + }, [isProtected, setEnvValueAsync, instanceParams, project, file, applyProtectedKeys, writeWholeFile, rawText]); + + const onDelete = useCallback(async (key: string) => { + if (isProtected) { + const response = await deleteEnvValueAsync({ ...instanceParams, project, file, key }); + applyProtectedKeys(response.keys); + } else { + await writeWholeFile(removeEnvKeys(rawText ?? '', key)); + } + }, [isProtected, deleteEnvValueAsync, instanceParams, project, file, applyProtectedKeys, writeWholeFile, rawText]); + + const [selectedName, setSelectedName] = useState(undefined); + const onRefresh = useCallback(() => refetch(), [refetch]); + + // The raw-text escape hatch, for plaintext mode only: on a protected file the editor would + // show the masked rendering, and saving that back would overwrite the real values. + const [showRawEditor, setShowRawEditor] = useState(false); + if (showRawEditor && !isProtected) { + return ( + <> + + + + ); + } + + return ( +
+
+

{openedEntry?.name}

+

+ {isProtected + ? ( + <> + + This Harper version protects environment secrets: values stay on the instance and are edited by key — + they can be replaced or removed, but never read back. + + ) + : 'Values are masked to prevent accidental disclosure. Revealing one is a deliberate action.'} +

+ {!isProtected && updatedFileContent !== undefined && ( +

+ Showing unsaved changes from the text editor — saving a secret writes those too. +

+ )} +
+ + {!isProtected && ( + + )} + +
+ ); +} diff --git a/src/features/instance/secrets/SecretModals.tsx b/src/features/instance/secrets/SecretModals.tsx new file mode 100644 index 000000000..de8a6a495 --- /dev/null +++ b/src/features/instance/secrets/SecretModals.tsx @@ -0,0 +1,322 @@ +/** + * Add/edit dialogs shared by every secrets surface: the cluster-level Secrets config page and the + * application editor's `.env` panel. The surfaces differ in copy and in what they can show (a + * plaintext `.env` can reveal the current value; encrypted/protected stores cannot), so both are + * injected — the dialogs own the form shell, validation, toasts, and the deliberate + * click-to-reveal affordance. + */ +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Form } from '@/components/ui/form/Form'; +import { FormControl } from '@/components/ui/form/FormControl'; +import { FormDescription } from '@/components/ui/form/FormDescription'; +import { FormField } from '@/components/ui/form/FormField'; +import { FormItem } from '@/components/ui/form/FormItem'; +import { FormLabel } from '@/components/ui/form/FormLabel'; +import { FormMessage } from '@/components/ui/form/FormMessage'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { ENV_KEY_REGEX, ENV_VALUE_MASK } from '@/lib/env/envFile'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { EyeIcon, Save, Trash2 } from 'lucide-react'; +import { ReactNode, useCallback, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +const secretKeySchema = z + .string() + .trim() + .min(1) + .regex(ENV_KEY_REGEX, { error: 'Letters, numbers, underscore, dash and dot only.' }); + +export function AddSecretModal({ + description, + valueDescription, + existingKeys, + onSubmit, + isModalOpen, + setIsModalOpen, +}: { + description: ReactNode; + valueDescription?: ReactNode; + /** Keys that already exist — adding one of these is rejected (edit it instead). */ + existingKeys?: string[]; + /** Persist the new secret; reject to keep the dialog open and surface the error. */ + onSubmit: (data: { key: string; value: string }) => Promise; + isModalOpen: boolean; + setIsModalOpen: (open: boolean) => void; +}) { + const schema = useMemo( + () => + z.object({ + key: secretKeySchema.refine((key) => !existingKeys?.includes(key), { + error: 'This key already exists — edit it instead.', + }), + value: z.string().min(1), + }), + [existingKeys], + ); + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { key: '', value: '' }, + }); + // Destructured unconditionally so react-hook-form tracks all three (see EditSecretModal). + const { isDirty, isValid, isSubmitting: isPending } = form.formState; + + const onSubmitClick = useCallback( + async (formData: z.infer) => { + try { + await onSubmit(formData); + form.reset(); + toast.success(`Secret "${formData.key}" saved.`); + setIsModalOpen(false); + } catch (error) { + toast.error(String(error)); + } + }, + [onSubmit, form, setIsModalOpen], + ); + + const onClickCancel = useCallback(() => { + form.reset(); + setIsModalOpen(false); + }, [form, setIsModalOpen]); + + return ( + + +
+ + + Add Secret + {description} + + + ( + + Key + + + + + + )} + /> + + ( + + Value + {valueDescription && {valueDescription}} + +