Add bulk add .env variables
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user