Skip to content

feat: secret generation #541

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import {
FaMagic,
FaCloudUploadAlt,
FaBan,
FaFileImport,
} from 'react-icons/fa'
import SecretRow from '@/components/environments/secrets/SecretRow'
import clsx from 'clsx'
Expand Down Expand Up @@ -63,6 +62,7 @@ import { userHasPermission } from '@/utils/access/permissions'
import Spinner from '@/components/common/Spinner'
import EnvFileDropZone from '@/components/environments/secrets/import/EnvFileDropZone'
import SingleEnvImportDialog from '@/components/environments/secrets/import/SingleEnvImportDialog'
import { GenerateSecretDialog } from '@/components/environments/secrets/generate/GenerateSecretDialog'

export default function EnvironmentPath({
params,
Expand All @@ -86,6 +86,7 @@ export default function EnvironmentPath({
const [globallyRevealed, setGloballyRevealed] = useState<boolean>(false)

const importDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null)
const generateDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null)

const [sort, setSort] = useState<SortOption>('-created')

Expand Down Expand Up @@ -212,6 +213,22 @@ export default function EnvironmentPath({
: setClientSecrets([...clientSecrets, newSecret])
}

const handleGeneratedSecret = (key: string, value: string) => {
const newSecret = {
id: `new-${crypto.randomUUID()}`,
updatedAt: null,
version: 1,
key: key || '',
value: value,
tags: [],
comment: '',
path: secretPath,
environment,
} as SecretType

setClientSecrets((prevSecrets) => [newSecret, ...prevSecrets])
}

const bulkAddSecrets = (secrets: SecretType[]) => {
const secretsWithImportFlag = secrets.map((secret) => ({
...secret,
Expand Down Expand Up @@ -769,15 +786,14 @@ export default function EnvironmentPath({
onClick={() => handleAddSecret(true)}
menuContent={
<div className="w-max flex flex-col items-start gap-1">
<Button variant="secondary" onClick={() => setFolderMenuIsOpen(true)}>
<Button variant="secondary" onClick={() => generateDialogRef.current?.openModal()}>
<div className="flex items-center gap-2">
<FaFolderPlus /> New Folder
<FaMagic /> Generate Secret
</div>
</Button>

<Button variant="secondary" onClick={() => importDialogRef.current?.openModal()}>
<div className="flex items-center gap-2">
<TbDownload /> Import secrets
<TbDownload /> Import Secrets
</div>
</Button>
</div>
Expand Down Expand Up @@ -961,11 +977,17 @@ export default function EnvironmentPath({

<SingleEnvImportDialog
environment={environment}
path={'/'}
path={secretPath}
addSecrets={bulkAddSecrets}
ref={importDialogRef}
/>

<GenerateSecretDialog
environment={environment}
onSecretGenerated={handleGeneratedSecret}
ref={generateDialogRef}
/>

{(clientSecrets.length > 0 || folders.length > 0) && (
<div className="flex items-center w-full">
<div className="px-9 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider w-1/3">
Expand All @@ -974,10 +996,10 @@ export default function EnvironmentPath({
<div className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider w-2/3 flex items-center justify-between">
value
<div className="flex items-center gap-4">
<Button variant="outline" onClick={toggleGlobalReveal}>
<Button variant="outline" onClick={toggleGlobalReveal} title={globallyRevealed ? 'Mask all secrets' : 'Reveal all secrets'}>
<div className="flex items-center gap-2">
{globallyRevealed ? <FaEyeSlash /> : <FaEye />}{' '}
{globallyRevealed ? 'Mask all' : 'Reveal all'}
<span className="hidden xl:inline">{globallyRevealed ? 'Mask all' : 'Reveal All'}</span>
</div>
</Button>
<Button
Expand All @@ -986,7 +1008,12 @@ export default function EnvironmentPath({
title="Download as .env file"
>
<div className="flex items-center gap-2">
<FaDownload /> Export as .env
<FaDownload /> <span className="hidden xl:inline">Export as .env</span>
</div>
</Button>
<Button variant="outline" onClick={() => setFolderMenuIsOpen(true)} title="Create a new folder">
<div className="flex items-center gap-2">
<FaFolderPlus /> <span className="hidden xl:inline">New Folder</span>
</div>
</Button>
<NewSecretMenu />
Expand Down Expand Up @@ -1044,7 +1071,15 @@ export default function EnvironmentPath({
</div>
}
>
<NewSecretMenu />
<div className="flex items-center gap-2 justify-center">
<NewSecretMenu />
{/* Add New Folder button specifically for the empty state */}
<Button variant="secondary" onClick={() => setFolderMenuIsOpen(true)}>
<div className="flex items-center gap-2">
<FaFolderPlus /> New Folder
</div>
</Button>
</div>
{!searchQuery && (
<div className="w-full max-w-screen-sm h-40 rounded-lg">
<EmptyStateFileImport />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
'use client'

import { Dialog, RadioGroup, Transition } from '@headlessui/react'
import { Button } from '@/components/common/Button'
import { EnvironmentType } from '@/apollo/graphql'
import { Fragment, useState, useRef, forwardRef, useImperativeHandle, useEffect, useCallback } from 'react'
import { FaTimes, FaMagic, FaEye, FaEyeSlash, FaUndo, FaUndoAlt } from 'react-icons/fa'
import _sodium from 'libsodium-wrappers-sumo'
import clsx from 'clsx'

interface GenerateSecretDialogProps {
environment: EnvironmentType
onSecretGenerated: (key: string, value: string) => void
}

type SecretFormat = 'hex' | 'base64' | 'password'

const formats: { name: SecretFormat; label: string }[] = [
{ name: 'hex', label: 'Hex' },
{ name: 'base64', label: 'Base64 (URL Safe)' },
{ name: 'password', label: 'Password' },
]

export const GenerateSecretDialog = forwardRef(
({ environment, onSecretGenerated }: GenerateSecretDialogProps, ref) => {
const [isOpen, setIsOpen] = useState<boolean>(false)
const [generatedValue, setGeneratedValue] = useState<string>('')
const [isLoading, setIsLoading] = useState<boolean>(false)
const [sodiumReady, setSodiumReady] = useState(false)
const [isRevealed, setIsRevealed] = useState<boolean>(false)
const [length, setLength] = useState<number>(32) // Default length in bytes
const [format, setFormat] = useState<SecretFormat>('hex') // Default format

const textareaRef = useRef<HTMLTextAreaElement>(null)

// Initialize Sodium
useEffect(() => {
const initSodium = async () => {
await _sodium.ready
setSodiumReady(true)
}
initSodium()
}, [])

// Memoize generateSecretValue with useCallback
const generateSecretValue = useCallback(() => {
if (!sodiumReady) {
console.error('Failed to initialize Sodium')
return ''
}
try {
let randomValue: string
const byteLength = format === 'password' ? Math.max(length, 8) : length; // Ensure decent length for passwords
const randomBytes = _sodium.randombytes_buf(byteLength)

switch (format) {
case 'hex':
randomValue = _sodium.to_hex(randomBytes)
break
case 'base64':
randomValue = _sodium.to_base64(randomBytes, _sodium.base64_variants.URLSAFE_NO_PADDING)
break
case 'password':
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+"
let password = ""
// Use the generated bytes to pick characters securely
for (let i = 0; i < byteLength; i++) {
password += charset[randomBytes[i] % charset.length]
}
// Ensure password length matches slider if slider adjusted > generated bytes length, truncate if necessary
randomValue = password.slice(0, length);
break
default:
randomValue = ''
}
return randomValue
} catch (error) {
console.error('Error generating secret:', error)
// Optionally show an error toast
return 'Error generating value'
}
}, [sodiumReady, length, format])

useEffect(() => {
if (isOpen && sodiumReady) {
setIsLoading(true)
setGeneratedValue(generateSecretValue())
setIsLoading(false)
}
}, [isOpen, sodiumReady, length, format, generateSecretValue])

const handleRegenerate = () => {
if (isOpen && sodiumReady) {
setIsLoading(true)
setGeneratedValue(generateSecretValue())
setIsLoading(false)
}
}

const closeModal = () => {
setIsOpen(false)
setGeneratedValue('')
setIsLoading(false)
setIsRevealed(false)
setLength(32) // Reset length
setFormat('hex') // Reset format
}

const openModal = () => {
setIsOpen(true)
}

const handleDone = () => {
onSecretGenerated('', generatedValue) // Pass empty key and current value
closeModal()
}

// Expose openModal and closeModal via ref
useImperativeHandle(ref, () => ({
openModal,
closeModal,
}))

const toggleReveal = () => setIsRevealed(!isRevealed)

return (
<>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/25 backdrop-blur-md" />
</Transition.Child>

<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="w-full max-w-3xl transform overflow-hidden rounded-2xl bg-neutral-100 dark:bg-neutral-900 p-6 text-left align-middle shadow-xl transition-all">
<Dialog.Title as="div" className="flex w-full justify-between items-start mb-4">
<div>
<h3 className="text-lg font-medium leading-6 text-black dark:text-white ">
<FaMagic className="inline mr-2 mb-1" /> Secret Generator
</h3>
<p className="text-neutral-500 text-sm">
Generate a secure random value.
</p>
</div>
<Button variant="text" onClick={closeModal}>
<FaTimes className="text-zinc-900 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300" />
</Button>
</Dialog.Title>

<div className="space-y-3 py-4">

<div className="space-y-1">
<label className="text-sm font-medium text-black dark:text-white block mb-1">Generated Value</label>
<textarea
ref={textareaRef}
rows={4}
readOnly
value={generatedValue}
className={clsx(
'w-full ph-no-capture font-mono p-2 rounded-md border border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800/50 focus:ring-1 focus:ring-emerald-500 focus:border-emerald-500',
!isRevealed && 'blur-sm select-none'
)}
/>
<div className="flex justify-end">
<Button
variant="outline"
onClick={toggleReveal}
title={isRevealed ? 'Mask value' : 'Reveal value'}
>
<span className="flex items-center gap-1 py-1">{isRevealed ? <FaEyeSlash /> : <FaEye />}Reveal </span>
</Button>
</div>
</div>

<div className="space-y-2">
<label htmlFor="length-slider" className="text-sm font-medium text-black dark:text-white">
Length: <span className="font-semibold">{length}</span> {format === 'password' ? 'characters' : 'bytes'}
{format !== 'password' && <span className='text-neutral-500'>({length * 8} bits)</span>}
</label>
<input
id="length-slider"
type="range"
min="1"
max={format === 'password' ? 64 : 256}
value={length}
onChange={(e) => setLength(parseInt(e.target.value, 10))}
className="w-full h-2 bg-neutral-200 dark:bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-emerald-500"
disabled={isLoading || !sodiumReady}
/>
</div>

<div className="flex justify-end">
<Button variant="secondary" onClick={handleRegenerate} isLoading={isLoading} disabled={!sodiumReady} title="Generate a new value with current settings">
<FaUndoAlt className="mr-2"/> Regenerate
</Button>
</div>

<div className="space-y-1">
<RadioGroup value={format} onChange={setFormat} disabled={isLoading || !sodiumReady}>
<RadioGroup.Label className="text-sm font-medium text-black dark:text-white">Format</RadioGroup.Label>
<div className="flex flex-wrap gap-2 mt-1">
{formats.map((fmt) => (
<RadioGroup.Option
key={fmt.name}
value={fmt.name}
as={Fragment}
>
{({ checked }) => (
<Button
type="button"
variant={checked ? 'primary' : 'secondary'}
onClick={() => setFormat(fmt.name)}
disabled={isLoading || !sodiumReady}
>
{fmt.label}
</Button>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
</div>

{!sodiumReady && <p className="text-xs text-amber-600">Initializing generator...</p>}
</div>

<div className="flex items-center justify-end gap-4 pt-5 border-t border-neutral-300 dark:border-neutral-700 mt-6">
<Button
variant="primary"
onClick={handleDone}
disabled={isLoading || !sodiumReady}
isLoading={isLoading}
>
Done
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
</>
)
}
)

GenerateSecretDialog.displayName = 'GenerateSecretDialog'