Add bulk add .env variables
This commit is contained in:
@@ -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<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'> }) => {
|
||||
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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardHeader className='flex-row items-center justify-between gap-4'>
|
||||
<CardTitle className='flex items-center gap-2 text-base'>
|
||||
<KeyRound className='size-4' />
|
||||
Project secrets
|
||||
</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>
|
||||
<CardContent className='space-y-4'>
|
||||
<form
|
||||
|
||||
Reference in New Issue
Block a user