diff --git a/frontend/app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx b/frontend/app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx index c2809080b..541e813cf 100644 --- a/frontend/app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx +++ b/frontend/app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx @@ -30,7 +30,6 @@ import { FaMagic, FaCloudUploadAlt, FaBan, - FaFileImport, } from 'react-icons/fa' import SecretRow from '@/components/environments/secrets/SecretRow' import clsx from 'clsx' @@ -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, @@ -86,6 +86,7 @@ export default function EnvironmentPath({ const [globallyRevealed, setGloballyRevealed] = useState(false) const importDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) + const generateDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) const [sort, setSort] = useState('-created') @@ -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, @@ -769,15 +786,14 @@ export default function EnvironmentPath({ onClick={() => handleAddSecret(true)} menuContent={
- -
@@ -961,11 +977,17 @@ export default function EnvironmentPath({ + + {(clientSecrets.length > 0 || folders.length > 0) && (
@@ -974,10 +996,10 @@ export default function EnvironmentPath({
value
- + @@ -1044,7 +1071,15 @@ export default function EnvironmentPath({
} > - +
+ + {/* Add New Folder button specifically for the empty state */} + +
{!searchQuery && (
diff --git a/frontend/components/environments/secrets/generate/GenerateSecretDialog.tsx b/frontend/components/environments/secrets/generate/GenerateSecretDialog.tsx new file mode 100644 index 000000000..9cb5d3e79 --- /dev/null +++ b/frontend/components/environments/secrets/generate/GenerateSecretDialog.tsx @@ -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(false) + const [generatedValue, setGeneratedValue] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [sodiumReady, setSodiumReady] = useState(false) + const [isRevealed, setIsRevealed] = useState(false) + const [length, setLength] = useState(32) // Default length in bytes + const [format, setFormat] = useState('hex') // Default format + + const textareaRef = useRef(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 ( + <> + + + +
+ + +
+
+ + + +
+

+ Secret Generator +

+

+ Generate a secure random value. +

+
+ +
+ +
+ +
+ +