Add bulk add .env variables
Build and Push Next App / quality (push) Successful in 1m23s
Build and Push Next App / build-next (push) Successful in 3m35s

This commit is contained in:
Gabriel Brown
2026-06-22 01:12:13 -05:00
parent 4114d5595c
commit 8ae6c4b533
@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useAction, useMutation, useQuery } from 'convex/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 { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -13,10 +13,82 @@ import {
CardContent, CardContent,
CardHeader, CardHeader,
CardTitle, CardTitle,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Input, Input,
Label, Label,
Textarea,
} from '@spoon/ui'; } 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<string, ParsedSecret>();
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'> }) => { export const SpoonSecretsForm = ({ spoonId }: { spoonId: Id<'spoons'> }) => {
const secrets = useQuery(api.spoonSecrets.listForSpoon, { spoonId }) ?? []; const secrets = useQuery(api.spoonSecrets.listForSpoon, { spoonId }) ?? [];
const createSecret = useAction(api.spoonSecretsNode.create); const createSecret = useAction(api.spoonSecretsNode.create);
@@ -24,7 +96,11 @@ export const SpoonSecretsForm = ({ spoonId }: { spoonId: Id<'spoons'> }) => {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [bulkText, setBulkText] = useState('');
const [saving, setSaving] = useState(false); 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<HTMLFormElement>) => { const save = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); 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 ( return (
<Card className='shadow-none'> <Card className='shadow-none'>
<CardHeader> <CardHeader className='flex-row items-center justify-between gap-4'>
<CardTitle className='flex items-center gap-2 text-base'> <CardTitle className='flex items-center gap-2 text-base'>
<KeyRound className='size-4' /> <KeyRound className='size-4' />
Project secrets Project secrets
</CardTitle> </CardTitle>
<Dialog open={bulkDialogOpen} onOpenChange={setBulkDialogOpen}>
<DialogTrigger asChild>
<Button type='button' variant='outline' size='sm'>
<Upload className='size-4' />
Import .env
</Button>
</DialogTrigger>
<DialogContent className='sm:max-w-2xl'>
<DialogHeader>
<DialogTitle>Import project secrets</DialogTitle>
<DialogDescription>
Paste a dotenv file or upload one. Existing names are updated,
and values are encrypted before storage.
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<div className='grid gap-2'>
<Label htmlFor='env-file'>Upload .env file</Label>
<Input
id='env-file'
type='file'
accept='.env,.txt,text/plain'
onChange={(event) =>
void readEnvFile(event.target.files?.[0])
}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='env-paste'>Paste .env content</Label>
<Textarea
id='env-paste'
value={bulkText}
className='min-h-56 font-mono text-xs'
placeholder={'AUTHENTIK_CLIENT_ID=...\nAUTHENTIK_SECRET=...'}
onChange={(event) => setBulkText(event.target.value)}
/>
</div>
<div className='border-border bg-muted/30 rounded-md border p-3 text-sm'>
<p className='font-medium'>
{parsedBulk.secrets.length} valid secret
{parsedBulk.secrets.length === 1 ? '' : 's'} ready to import
</p>
{parsedBulk.secrets.length ? (
<div className='text-muted-foreground mt-2 flex flex-wrap gap-2'>
{parsedBulk.secrets.slice(0, 12).map((secret) => (
<span
key={secret.name}
className='bg-background rounded border px-2 py-1 font-mono text-xs'
>
{secret.name}
</span>
))}
{parsedBulk.secrets.length > 12 ? (
<span className='px-2 py-1 text-xs'>
+{parsedBulk.secrets.length - 12} more
</span>
) : null}
</div>
) : null}
{parsedBulk.skipped.length ? (
<div className='text-muted-foreground mt-3 space-y-1 text-xs'>
{parsedBulk.skipped.slice(0, 4).map((line) => (
<p key={line}>{line}</p>
))}
{parsedBulk.skipped.length > 4 ? (
<p>+{parsedBulk.skipped.length - 4} more skipped lines</p>
) : null}
</div>
) : null}
</div>
</div>
<DialogFooter>
<Button
type='button'
onClick={() => void importBulk()}
disabled={bulkSaving || !parsedBulk.secrets.length}
>
<FileText className='size-4' />
{bulkSaving ? 'Importing...' : 'Import secrets'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
<form <form