diff --git a/apps/next/src/components/spoons/spoon-secrets-form.tsx b/apps/next/src/components/spoons/spoon-secrets-form.tsx index 42de6f8..b279857 100644 --- a/apps/next/src/components/spoons/spoon-secrets-form.tsx +++ b/apps/next/src/components/spoons/spoon-secrets-form.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useAction, useMutation, useQuery } from 'convex/react'; -import { KeyRound, Trash2 } from 'lucide-react'; +import { FileText, KeyRound, Trash2, Upload } from 'lucide-react'; import { toast } from 'sonner'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; @@ -13,10 +13,82 @@ import { CardContent, CardHeader, CardTitle, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, Input, Label, + Textarea, } from '@spoon/ui'; +type ParsedSecret = { + name: string; + value: string; +}; + +type ParseResult = { + secrets: ParsedSecret[]; + skipped: string[]; +}; + +const secretNamePattern = /^[A-Z_][A-Z0-9_]*$/; + +const unquoteValue = (value: string) => { + const trimmed = value.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + const inner = trimmed.slice(1, -1); + return trimmed.startsWith('"') + ? inner + .replaceAll('\\n', '\n') + .replaceAll('\\r', '\r') + .replaceAll('\\t', '\t') + .replaceAll('\\"', '"') + .replaceAll('\\\\', '\\') + : inner; + } + return trimmed; +}; + +const parseDotenv = (source: string): ParseResult => { + const secrets = new Map(); + const skipped: string[] = []; + + source.split(/\r?\n/).forEach((rawLine, index) => { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) return; + + const withoutExport = line.startsWith('export ') + ? line.slice('export '.length).trim() + : line; + const equalsIndex = withoutExport.indexOf('='); + if (equalsIndex <= 0) { + skipped.push(`Line ${index + 1}: missing KEY=value`); + return; + } + + const name = withoutExport.slice(0, equalsIndex).trim().toUpperCase(); + const value = unquoteValue(withoutExport.slice(equalsIndex + 1)); + if (!secretNamePattern.test(name)) { + skipped.push(`Line ${index + 1}: invalid secret name`); + return; + } + if (!value) { + skipped.push(`Line ${index + 1}: empty value`); + return; + } + secrets.set(name, { name, value }); + }); + + return { secrets: [...secrets.values()], skipped }; +}; + export const SpoonSecretsForm = ({ spoonId }: { spoonId: Id<'spoons'> }) => { const secrets = useQuery(api.spoonSecrets.listForSpoon, { spoonId }) ?? []; const createSecret = useAction(api.spoonSecretsNode.create); @@ -24,7 +96,11 @@ export const SpoonSecretsForm = ({ spoonId }: { spoonId: Id<'spoons'> }) => { const [name, setName] = useState(''); const [value, setValue] = useState(''); const [description, setDescription] = useState(''); + const [bulkText, setBulkText] = useState(''); const [saving, setSaving] = useState(false); + const [bulkSaving, setBulkSaving] = useState(false); + const [bulkDialogOpen, setBulkDialogOpen] = useState(false); + const parsedBulk = parseDotenv(bulkText); const save = async (event: React.FormEvent) => { event.preventDefault(); @@ -48,13 +124,134 @@ export const SpoonSecretsForm = ({ spoonId }: { spoonId: Id<'spoons'> }) => { } }; + const importBulk = async () => { + if (!parsedBulk.secrets.length) { + toast.error('No valid secrets found.'); + return; + } + setBulkSaving(true); + try { + for (const secret of parsedBulk.secrets) { + await createSecret({ + spoonId, + name: secret.name, + value: secret.value, + description: 'Imported from .env', + }); + } + setBulkText(''); + setBulkDialogOpen(false); + toast.success(`${parsedBulk.secrets.length} secrets imported.`); + } catch (error) { + console.error(error); + toast.error('Could not import secrets.'); + } finally { + setBulkSaving(false); + } + }; + + const readEnvFile = async (file?: File) => { + if (!file) return; + try { + setBulkText(await file.text()); + } catch (error) { + console.error(error); + toast.error('Could not read .env file.'); + } + }; + return ( - + Project secrets + + + + + + + Import project secrets + + Paste a dotenv file or upload one. Existing names are updated, + and values are encrypted before storage. + + + +
+
+ + + void readEnvFile(event.target.files?.[0]) + } + /> +
+
+ +