'use client'; import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { ChevronsUpDownIcon, Copy, CopyIcon, Eye, EyeOff, EyeOffIcon, InfoIcon, TriangleAlertIcon, } from 'lucide-react'; import { Subject, debounceTime } from 'rxjs'; 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'; import { DynamicFormInput } from './dynamic-form-input'; import { envVariables } from '@/app/variables/lib/env-variables-model'; import { updateEnvironmentVariableAction } from '@/app/variables/lib/server-actions'; import { EnvModeSelector } from '@/components/env-mode-selector'; export function AppEnvironmentVariablesManager({ state, }: React.PropsWithChildren<{ state: AppEnvState; }>) { return ; } function EnvListDisplay({ groups, className, hideSecret = false, }: { groups: Array<{ category: string; variables: Array; }>; className: string; hideSecret?: boolean; }) { return (
{groups.map((group) => (
# {group.category}
{group.variables.map((variable) => { const model = envVariables.find( (item) => item.name === variable.key, ); const isSecret = model?.secret; const value = isSecret && hideSecret ? '••••••••' : variable.effectiveValue; return ( {variable.key}:{' '} {value && ( {value} )} ); })}
))}
); } function EnvList({ appState }: { appState: AppEnvState }) { const [showValues, setShowValues] = useState>({}); const [search, setSearch] = useState(''); const searchParams = useSearchParams(); const showSecretVars = searchParams.get('secret') === 'true'; const showPublicVars = searchParams.get('public') === 'true'; const showPrivateVars = searchParams.get('private') === 'true'; const showOverriddenVars = searchParams.get('overridden') === 'true'; const showInvalidVars = searchParams.get('invalid') === 'true'; const showDeprecatedVars = searchParams.get('deprecated') === 'true'; const toggleShowValue = (key: string) => { setShowValues((prev) => ({ ...prev, [key]: !prev[key], })); }; const renderValue = (value: string, isVisible: boolean) => { if (!isVisible) { if (!value) { return `(empty)`; } return '••••••••'; } return value; }; const allVariables = getEffectiveVariablesValue(appState); const subject$ = useMemo( () => new Subject<{ name: string; value: string; }>(), [], ); useEffect(() => { const subscription = subject$ .pipe(debounceTime(1000)) .subscribe((props) => { updateEnvironmentVariableAction({ ...props, mode: appState.mode, }) .then((result) => { toast.success(result.message); }) .catch((err) => { toast.error(`Failed to update ${props.name}: ${err.message}`); }); }); return () => { return subscription.unsubscribe(); }; }, [subject$]); const onValueChanged = useCallback( (props: { value: string; name: string }) => { subject$.next({ name: props.name, value: props.value, }); }, [subject$], ); const renderVariable = (varState: EnvVariableState) => { const model = envVariables.find( (variable) => variable.name === varState.key, ); const isClientBundledValue = varState.key.startsWith('NEXT_PUBLIC_'); const isValueVisible = showValues[varState.key] ?? !model?.secret; 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)}
} >
{(hint) => (
{hint}
)}
{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. {(deprecated) => ( Deprecated
This variable is deprecated
Reason: {deprecated.reason}
{deprecated.alternative && (
Use instead:{' '} {deprecated.alternative}
)}
)}
{varState.effectiveSource === 'MISSING' ? `The variable ${varState.key} is required but missing` : `The value for ${varState.key} is invalid`}
{varState.validation.error?.issues.map((issue, index) => (
• {issue}
))}
{/* 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}
))}
); }; const filterVariable = (varState: EnvVariableState) => { const model = envVariables.find( (variable) => variable.name === varState.key, ); if ( !search && !showSecretVars && !showPublicVars && !showPrivateVars && !showInvalidVars && !showOverriddenVars && !showDeprecatedVars ) { return true; } const isSecret = model?.secret ?? false; const isPublic = varState.key.startsWith('NEXT_PUBLIC_'); const isPrivate = !isPublic; const isInSearch = search ? varState.key.toLowerCase().includes(search.toLowerCase()) : true; if (showPublicVars && isInSearch) { return isPublic; } if (showSecretVars && isInSearch) { return isSecret; } if (showPrivateVars && isInSearch) { return isPrivate; } if (showOverriddenVars && isInSearch) { return varState.isOverridden; } if (showInvalidVars && isInSearch) { return !varState.validation.success; } if (showDeprecatedVars && isInSearch) { return !!model?.deprecated; } return isInSearch; }; // Update groups to use allVarsWithValidation instead of appState.variables const groups = getGroups(appState, filterVariable); return (
setSearch(e.target.value)} />
{groups.map((group) => { const visibleVariables = group.variables.filter( (item) => item.isVisible, ); if (visibleVariables.length === 0) { return null; } return (
{group.category}
{group.variables.map((item) => { return ( {renderVariable(item)} ); })}
); })}
No variables found
); } function FilterSwitcher(props: { filters: { secret: boolean; public: boolean; overridden: boolean; private: boolean; invalid: boolean; deprecated: 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 deprecatedVars = props.filters.deprecated; 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 (deprecatedVars) filters.push('Deprecated'); if (filters.length === 0) return 'Filter variables'; return filters.join(', '); }; const allSelected = !secretVars && !publicVars && !overriddenVars && !invalidVars && !deprecatedVars; return ( {buttonLabel()} } /> { handleFilterChange('all', true); }} > All { handleFilterChange('secret', !secretVars); }} > Secret { handleFilterChange('private', !privateVars); }} > Private { handleFilterChange('public', !publicVars); }} > Public { handleFilterChange('invalid', !invalidVars); }} > Invalid { handleFilterChange('overridden', !overriddenVars); }} > Overridden { handleFilterChange('deprecated', !deprecatedVars); }} > Deprecated ); } function Summary({ appState }: { appState: AppEnvState }) { const varsArray = Object.values(appState.variables); const overridden = varsArray.filter((variable) => variable.isOverridden); const handleFilterChange = useUpdateFilteredVariables(); // Find all variables with errors (including missing required and contextual validation) const variablesWithErrors = varsArray.filter((variable) => { return !variable.validation.success; }); // Find deprecated variables const deprecatedVariables = varsArray.filter((variable) => { const model = envVariables.find((env) => env.name === variable.key); return !!model?.deprecated; }); const validVariables = varsArray.length - variablesWithErrors.length; return (
{validVariables} Valid 0, 'text-green-500': variablesWithErrors.length === 0, })} > {variablesWithErrors.length} Invalid 0}> 0 })} > {overridden.length} Overridden 0}> 0 })} > {deprecatedVariables.length} Deprecated
0}> 0}> { let data = ''; const groups = getGroups(appState, () => true); groups.forEach((group) => { data += `# ${group.category}\n`; group.variables.forEach((variable) => { data += `${variable.key}=${variable.effectiveValue}\n`; }); data += '\n'; }); const promise = copyToClipboard(data); toast.promise(promise, { loading: 'Copying environment variables...', success: 'Environment variables copied to clipboard.', error: 'Failed to copy environment variables to clipboard', }); }} > Copy env file to clipboard } /> Copy environment variables to clipboard. You can place it in your hosting provider to set up the full environment.
); } 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'); searchParams.delete('deprecated'); }; 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]); } async function copyToClipboard(text: string) { try { await navigator.clipboard.writeText(text); } catch (err) { console.error('Failed to copy:', err); } } function getGroups( appState: AppEnvState, filterVariable: (variable: EnvVariableState) => boolean, ) { return Object.values(appState.variables).reduce( (acc, variable) => { const group = acc.find((group) => group.category === variable.category); variable.isVisible = filterVariable(variable); if (!group) { acc.push({ category: variable.category, variables: [variable], }); } else { group.variables.push(variable); } return acc; }, [] as Array<{ category: string; variables: Array }>, ); }