'use client'; import { Fragment, useCallback, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { envVariables } from '@/app/variables/lib/env-variables-model'; import { EnvModeSelector } from '@/components/env-mode-selector'; import { ChevronDown, ChevronUp, ChevronsUpDownIcon, Copy, Eye, EyeOff, EyeOffIcon, InfoIcon, } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Badge } from '@kit/ui/badge'; import { Button } from '@kit/ui/button'; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger, } from '@kit/ui/dropdown-menu'; import { Heading } from '@kit/ui/heading'; import { If } from '@kit/ui/if'; import { Input } from '@kit/ui/input'; import { toast } from '@kit/ui/sonner'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@kit/ui/tooltip'; import { cn } from '@kit/ui/utils'; import { AppEnvState, EnvVariableState } from '../lib/types'; type ValidationResult = { success: boolean; error?: { issues: Array<{ message: string }>; }; }; type VariableRecord = Record; export function AppEnvironmentVariablesManager({ state, }: React.PropsWithChildren<{ state: AppEnvState; }>) { return (
Application: {state.appName}
); } function EnvList({ appState }: { appState: AppEnvState }) { const [expandedVars, setExpandedVars] = useState>({}); const [showValues, setShowValues] = useState>({}); const [search, setSearch] = useState(''); const searchParams = useSearchParams(); const secretVars = searchParams.get('secret') === 'true'; const publicVars = searchParams.get('public') === 'true'; const privateVars = searchParams.get('private') === 'true'; const overriddenVars = searchParams.get('overridden') === 'true'; const invalidVars = searchParams.get('invalid') === 'true'; const toggleExpanded = (key: string) => { setExpandedVars((prev) => ({ ...prev, [key]: !prev[key], })); }; const toggleShowValue = (key: string) => { setShowValues((prev) => ({ ...prev, [key]: !prev[key], })); }; const copyToClipboard = async (text: string) => { try { await navigator.clipboard.writeText(text); } catch (err) { console.error('Failed to copy:', err); } }; const renderValue = (value: string, isVisible: boolean) => { if (!isVisible) { return '••••••••'; } return value || '(empty)'; }; const allVariables = getEffectiveVariablesValue(appState); // Create a map of all variables including missing ones that have contextual validation const allVarsWithValidation = envVariables.reduce< Record >((acc, model) => { // If the variable exists in appState, use that const existingVar = appState.variables[model.name]; if (existingVar) { acc[model.name] = existingVar; } else if ( // Show missing variables if they: model.required || // Are marked as required model.contextualValidation // OR have contextual validation ) { // If it doesn't exist but is required or has contextual validation, create an empty state acc[model.name] = { key: model.name, effectiveValue: '', effectiveSource: 'MISSING', category: model.category, isOverridden: false, definitions: [], }; } return acc; }, {}); const renderVariable = (varState: EnvVariableState) => { const isExpanded = expandedVars[varState.key] ?? false; const isClientBundledValue = varState.key.startsWith('NEXT_PUBLIC_'); const isValueVisible = showValues[varState.key] ?? isClientBundledValue; const model = envVariables.find( (variable) => variable.name === varState.key, ); // Enhanced validation logic to handle both regular and contextual validation let validation: ValidationResult = { success: true, }; if (model) { // First check if it's required but missing if (model.required && !varState.effectiveValue) { validation = { success: false, error: { issues: [ { message: `This variable is required but missing from your environment files`, }, ], }, }; } else if (model.contextualValidation) { // Then check contextual validation const dependenciesMet = model.contextualValidation.dependencies.some( (dep) => { const dependencyValue = allVariables[dep.variable] ?? ''; return dep.condition(dependencyValue, allVariables); }, ); if (dependenciesMet) { // Only check for missing value or run validation if dependencies are met if (!varState.effectiveValue) { const dependencyErrors = model.contextualValidation.dependencies .map((dep) => { const dependencyValue = allVariables[dep.variable] ?? ''; const shouldValidate = dep.condition( dependencyValue, allVariables, ); if (shouldValidate) { const { success } = model.contextualValidation!.validate({ value: varState.effectiveValue, variables: allVariables, mode: appState.mode, }); if (success) { return null; } return dep.message; } return null; }) .filter((message): message is string => message !== null); validation = { success: dependencyErrors.length === 0, error: { issues: dependencyErrors.map((message) => ({ message })), }, }; } else { // If we have a value and dependencies are met, run contextual validation const result = model.contextualValidation.validate({ value: varState.effectiveValue, variables: allVariables, mode: appState.mode, }); if (!result.success) { validation = { success: false, error: { issues: result.error.issues.map((issue) => ({ message: issue.message, })), }, }; } } } } else if (model.validate && varState.effectiveValue) { // Only run regular validation if: // 1. There's no contextual validation // 2. There's a value to validate const result = model.validate({ value: varState.effectiveValue, variables: allVariables, mode: appState.mode, }); if (!result.success) { validation = { success: false, error: { issues: result.error.issues.map((issue) => ({ message: issue.message, })), }, }; } } } const canExpand = varState.definitions.length > 1 || !validation.success; return (
{varState.key} {model?.required && Required} {varState.effectiveSource === 'MISSING' && ( { const dependencyValue = allVariables[dep.variable] ?? ''; const shouldValidate = dep.condition( dependencyValue, allVariables, ); if (!shouldValidate) { return false; } return !model.contextualValidation!.validate({ value: varState.effectiveValue, variables: allVariables, mode: appState.mode, }).success; }) ? 'destructive' : 'outline' } > Missing )} {varState.isOverridden && ( Overridden )}
{(model) => (
{model.description}
)}
{renderValue(varState.effectiveValue, isValueVisible)}
{canExpand && ( )}
{isClientBundledValue ? `Public variable` : `Private variable`} {isClientBundledValue ? `This variable will be bundled into the client side. If this is a private variable, do not use "NEXT_PUBLIC".` : `This variable is private and will not be bundled client side, so you cannot access it from React components rendered client side`} Secret Variable This is a secret key. Keep it safe! {varState.effectiveSource} {varState.effectiveSource === '.env.local' ? `These variables are specific to this machine and are not committed` : varState.effectiveSource === '.env.development' ? `These variables are only being used during development` : varState.effectiveSource === '.env' ? `These variables are shared under all modes` : `These variables are only used in production mode`} Overridden in {varState.effectiveSource} This variable was overridden by a variable in{' '} {varState.effectiveSource} Invalid Value This variable has an invalid value. Drop down to view the errors.
{isExpanded && canExpand && (
Errors {varState.effectiveSource === 'MISSING' ? 'Missing Required Variable' : 'Invalid Value'}
{varState.effectiveSource === 'MISSING' ? `The variable ${varState.key} is required but missing from your environment files:` : `The value for ${varState.key} is invalid:`}
{/* Enhanced error display */}
{validation.error?.issues.map((issue, index) => (
• {issue.message}
))}
{/* Display dependency information if available */} {model?.contextualValidation?.dependencies && (
Dependencies:
{model.contextualValidation.dependencies.map( (dep, index) => (
• Requires valid {dep.variable.toUpperCase()}{' '} when {dep.message}
), )}
)}
1}>
Override Chain
{varState.definitions.map((def) => (
{def.source}
{renderValue(def.value, isValueVisible)}
))}
)}
); }; const filterVariable = (varState: EnvVariableState) => { const model = envVariables.find( (variable) => variable.name === varState.key, ); if ( !search && !secretVars && !publicVars && !privateVars && !invalidVars && !overriddenVars ) { return true; } const isSecret = model?.secret; const isPublic = varState.key.startsWith('NEXT_PUBLIC_'); const isPrivate = !isPublic; const isInSearch = search ? varState.key.toLowerCase().includes(search.toLowerCase()) : true; if (isPublic && publicVars && isInSearch) { return true; } if (isSecret && secretVars && isInSearch) { return true; } if (isPrivate && privateVars && isInSearch) { return true; } if (overriddenVars && varState.isOverridden && isInSearch) { return true; } if (invalidVars) { const allVariables = getEffectiveVariablesValue(appState); let hasError = false; if (model) { if (model.contextualValidation) { // Check for missing or invalid dependencies const dependencyErrors = model.contextualValidation.dependencies .map((dep) => { const dependencyValue = allVariables[dep.variable] ?? ''; const shouldValidate = dep.condition( dependencyValue, allVariables, ); if (shouldValidate) { const { error } = model.contextualValidation!.validate({ value: varState.effectiveValue, variables: allVariables, mode: appState.mode, }); return error; } return false; }) .filter(Boolean); if (dependencyErrors.length > 0) { hasError = true; } } else if (model.validate) { // Fall back to regular validation const result = model.validate({ value: varState.effectiveValue, variables: allVariables, mode: appState.mode, }); hasError = !result.success; } } if (hasError && isInSearch) return true; } if (isInSearch) { return true; } return false; }; // Update groups to use allVarsWithValidation instead of appState.variables const groups = Object.values(allVarsWithValidation) .filter(filterVariable) .reduce( (acc, variable) => { const group = acc.find((group) => group.category === variable.category); if (!group) { acc.push({ category: variable.category, variables: [variable], }); } else { group.variables.push(variable); } return acc; }, [] as Array<{ category: string; variables: Array }>, ); return (
setSearch(e.target.value)} /> Create a report from the environment variables. Useful for creating support tickets.
{groups.map((group) => (
{group.category}
{group.variables.map((item) => { return ( {renderVariable(item)} ); })}
))}
No variables found
); } function createReportFromEnvState(state: AppEnvState) { let report = ``; for (const key in state.variables) { const variable = state.variables[key]; const variableReport = `${key}: ${JSON.stringify(variable, null, 2)}`; ``; report += variableReport + '\n'; } return report; } function FilterSwitcher(props: { filters: { secret: boolean; public: boolean; overridden: boolean; private: boolean; invalid: boolean; }; }) { const secretVars = props.filters.secret; const publicVars = props.filters.public; const overriddenVars = props.filters.overridden; const privateVars = props.filters.private; const invalidVars = props.filters.invalid; const handleFilterChange = useUpdateFilteredVariables(); const buttonLabel = () => { const filters = []; if (secretVars) filters.push('Secret'); if (publicVars) filters.push('Public'); if (overriddenVars) filters.push('Overridden'); if (privateVars) filters.push('Private'); if (invalidVars) filters.push('Invalid'); if (filters.length === 0) return 'Filter variables'; return filters.join(', '); }; const allSelected = !secretVars && !publicVars && !overriddenVars && !invalidVars; return ( { handleFilterChange('all', true); }} > All { handleFilterChange('secret', !secretVars); }} > Secret { handleFilterChange('private', !privateVars); }} > Private { handleFilterChange('public', !publicVars); }} > Public { handleFilterChange('invalid', !invalidVars); }} > Invalid { handleFilterChange('overridden', !overriddenVars); }} > Overridden ); } function Summary({ appState }: { appState: AppEnvState }) { const varsArray = Object.values(appState.variables); const allVariables = getEffectiveVariablesValue(appState); const overridden = varsArray.filter((variable) => variable.isOverridden); const handleFilterChange = useUpdateFilteredVariables(); // Find all variables with errors (including missing required and contextual validation) const errors = envVariables.reduce((acc, model) => { // Get the current value of this variable const varState = appState.variables[model.name]; const value = varState?.effectiveValue; let hasError = false; // Check if it's required but missing if (model.required && !value) { hasError = true; } else if (model.contextualValidation) { // Check if any dependency conditions are met const dependenciesErrors = model.contextualValidation.dependencies.some( (dep) => { const dependencyValue = allVariables[dep.variable] ?? ''; const shouldValidate = dep.condition(dependencyValue, allVariables); if (shouldValidate) { const { error } = model.contextualValidation!.validate({ value: varState?.effectiveValue ?? '', variables: allVariables, mode: appState.mode, }); return error; } }, ); if (dependenciesErrors) { hasError = true; } } else if (model.validate && value) { // Only run regular validation if: // 1. There's no contextual validation // 2. There's a value to validate const result = model.validate({ value, variables: allVariables, mode: appState.mode, }); if (!result.success) { hasError = true; } } if (hasError) { acc.push(model.name); } return acc; }, []); const validVariables = varsArray.length - errors.length; return (
{validVariables} Valid 0, 'text-green-500': errors.length === 0, })} > {errors.length} Invalid 0}> 0 })} > {overridden.length} Overridden
0}>
); } function getEffectiveVariablesValue( appState: AppEnvState, ): Record { const varsArray = Object.values(appState.variables); return varsArray.reduce( (acc, variable) => ({ ...acc, [variable.key]: variable.effectiveValue, }), {}, ); } function useUpdateFilteredVariables() { const router = useRouter(); const handleFilterChange = (key: string, value: boolean, reset = false) => { const searchParams = new URLSearchParams(window.location.search); const path = window.location.pathname; const resetAll = () => { searchParams.delete('secret'); searchParams.delete('public'); searchParams.delete('overridden'); searchParams.delete('private'); searchParams.delete('invalid'); }; if (reset) { resetAll(); } if (key === 'all' && value) { resetAll(); } else { if (!value) { searchParams.delete(key); } else { searchParams.set(key, 'true'); } } router.push(`${path}?${searchParams.toString()}`); }; return useCallback(handleFilterChange, [router]); }