'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 }>;
};
};
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 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 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)}
toggleShowValue(varState.key)}
>
{isValueVisible ? : }
copyToClipboard(varState.effectiveValue)}
size={'icon'}
>
{canExpand && (
toggleExpanded(varState.key)}
>
{isExpanded ? (
) : (
)}
)}
{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 &&
!showSecretVars &&
!showPublicVars &&
!showPrivateVars &&
!showInvalidVars &&
!showOverriddenVars
) {
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) {
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;
}
}
return hasError && isInSearch;
}
return isInSearch;
};
// 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)}
/>
{
const report = createReportFromEnvState(appState);
const promise = copyToClipboard(report);
toast.promise(promise, {
loading: 'Copying report...',
success:
'Report copied to clipboard. Please paste it in your ticket.',
error: 'Failed to copy report to clipboard',
});
}}
>
Copy to Clipboard
Create a report from the environment variables. Useful for
creating support tickets.
{groups.map((group) => (
{group.category}
{group.variables.map((item) => {
return (
{renderVariable(item)}
);
})}
))}
);
}
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 (
{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
);
}
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}>
handleFilterChange('invalid', true, true)}
>
Display Invalid only
);
}
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]);
}