diff --git a/apps/dev-tool/app/variables/components/app-environment-variables-manager.tsx b/apps/dev-tool/app/variables/components/app-environment-variables-manager.tsx index 9f58a7d55..1c635a0d8 100644 --- a/apps/dev-tool/app/variables/components/app-environment-variables-manager.tsx +++ b/apps/dev-tool/app/variables/components/app-environment-variables-manager.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Fragment, useState } from 'react'; +import { Fragment, useCallback, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; @@ -13,6 +13,7 @@ import { Copy, Eye, EyeOff, + EyeOffIcon, InfoIcon, } from 'lucide-react'; @@ -39,6 +40,15 @@ 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<{ @@ -97,36 +107,149 @@ function EnvList({ appState }: { appState: AppEnvState }) { 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_'); - - // public variables are always visible const isValueVisible = showValues[varState.key] ?? isClientBundledValue; - // grab model is it's a kit variable const model = envVariables.find( (variable) => variable.name === varState.key, ); - const allVariables = Object.values(appState.variables).reduce( - (acc, variable) => ({ - ...acc, - [variable.key]: variable.effectiveValue, - }), - {}, - ); + // Enhanced validation logic to handle both regular and contextual validation + let validation: ValidationResult = { + success: true, + }; - const validation = model?.validate - ? model.validate({ + 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, - }) - : { - success: true, - error: undefined, - }; + }); + + if (!result.success) { + validation = { + success: false, + error: { + issues: result.error.issues.map((issue) => ({ + message: issue.message, + })), + }, + }; + } + } + } const canExpand = varState.definitions.length > 1 || !validation.success; @@ -134,12 +257,46 @@ function EnvList({ appState }: { appState: AppEnvState }) {
-
-
+
+
{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 )} @@ -147,7 +304,7 @@ function EnvList({ appState }: { appState: AppEnvState }) { {(model) => ( -
+
{model.description} @@ -238,33 +395,35 @@ function EnvList({ appState }: { appState: AppEnvState }) { - - {varState.effectiveSource} + + + {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`} - - - - + + {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`} + + + + + @@ -313,13 +472,45 @@ function EnvList({ appState }: { appState: AppEnvState }) { - Invalid Value + + {varState.effectiveSource === 'MISSING' + ? 'Missing Required Variable' + : 'Invalid Value'} + - The value for {varState.key} is invalid: -
-                      {JSON.stringify(validation, null, 2)}
-                    
+
+
+ {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} +
+ ), + )} +
+ )} +
@@ -401,30 +592,63 @@ function EnvList({ appState }: { appState: AppEnvState }) { } if (invalidVars) { - const allVariables = Object.values(appState.variables).reduce( - (acc, variable) => ({ - ...acc, - [variable.key]: variable.effectiveValue, - }), - {}, - ); + const allVariables = getEffectiveVariablesValue(appState); - const hasError = - model && model.validate - ? !model.validate({ - value: varState.effectiveValue, - variables: allVariables, - mode: appState.mode, - }).success - : false; + 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; }; - const groups = Object.values(appState.variables) + // Update groups to use allVarsWithValidation instead of appState.variables + const groups = Object.values(allVarsWithValidation) .filter(filterVariable) .reduce( (acc, variable) => { @@ -445,7 +669,7 @@ function EnvList({ appState }: { appState: AppEnvState }) { ); return ( -
+
@@ -501,7 +725,7 @@ function EnvList({ appState }: { appState: AppEnvState }) {
-
+
{groups.map((group) => ( @@ -561,34 +785,13 @@ function FilterSwitcher(props: { invalid: boolean; }; }) { - const router = useRouter(); - 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 = (key: string, value: boolean) => { - const searchParams = new URLSearchParams(window.location.search); - const path = window.location.pathname; - - if (key === 'all' && value) { - searchParams.delete('secret'); - searchParams.delete('public'); - searchParams.delete('overridden'); - searchParams.delete('private'); - searchParams.delete('invalid'); - } else { - if (!value) { - searchParams.delete(key); - } else { - searchParams.set(key, 'true'); - } - } - - router.push(`${path}?${searchParams.toString()}`); - }; + const handleFilterChange = useUpdateFilteredVariables(); const buttonLabel = () => { const filters = []; @@ -678,44 +881,155 @@ function FilterSwitcher(props: { 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(); - const allVariables = varsArray.reduce( + // 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, }), {}, ); - - const errors = varsArray.filter((variable) => { - const model = envVariables.find((v) => variable.key === v.name); - - const validation = - model && model.validate - ? model.validate({ - value: variable.effectiveValue, - variables: allVariables, - mode: appState.mode, - }) - : { - success: true, - }; - - return !validation.success; - }); - - return ( -
-
- - {errors.length} Errors - - - - {overridden.length} Overridden Variables - -
-
- ); +} + +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]); } diff --git a/apps/dev-tool/app/variables/lib/env-scanner.ts b/apps/dev-tool/app/variables/lib/env-scanner.ts index 59a387397..e1c164998 100644 --- a/apps/dev-tool/app/variables/lib/env-scanner.ts +++ b/apps/dev-tool/app/variables/lib/env-scanner.ts @@ -1,9 +1,9 @@ import 'server-only'; -import { envVariables } from '@/app/variables/lib/env-variables-model'; import fs from 'fs/promises'; import path from 'path'; +import { envVariables } from './env-variables-model'; import { AppEnvState, EnvFileInfo, diff --git a/apps/dev-tool/app/variables/lib/env-variables-model.ts b/apps/dev-tool/app/variables/lib/env-variables-model.ts index b5537f47b..677d61120 100644 --- a/apps/dev-tool/app/variables/lib/env-variables-model.ts +++ b/apps/dev-tool/app/variables/lib/env-variables-model.ts @@ -1,10 +1,26 @@ import { EnvMode } from '@/app/variables/lib/types'; import { z } from 'zod'; +type DependencyRule = { + variable: string; + condition: (value: string, variables: Record) => boolean; + message: string; +}; + +type ContextualValidation = { + dependencies: DependencyRule[]; + validate: (props: { + value: string; + variables: Record; + mode: EnvMode; + }) => z.SafeParseReturnType; +}; + export type EnvVariableModel = { name: string; description: string; secret?: boolean; + required?: boolean; category: string; test?: (value: string) => Promise; validate?: (props: { @@ -12,6 +28,7 @@ export type EnvVariableModel = { variables: Record; mode: EnvMode; }) => z.SafeParseReturnType; + contextualValidation?: ContextualValidation; }; export const envVariables: EnvVariableModel[] = [ @@ -20,6 +37,7 @@ export const envVariables: EnvVariableModel[] = [ description: 'The URL of your site, used for generating absolute URLs. Must include the protocol.', category: 'Site Configuration', + required: true, validate: ({ value, mode }) => { if (mode === 'development') { return z @@ -47,6 +65,7 @@ export const envVariables: EnvVariableModel[] = [ description: "Your product's name, used consistently across the application interface.", category: 'Site Configuration', + required: true, validate: ({ value }) => { return z .string() @@ -62,6 +81,7 @@ export const envVariables: EnvVariableModel[] = [ description: "The site's title tag content, crucial for SEO and browser display.", category: 'Site Configuration', + required: true, validate: ({ value }) => { return z .string() @@ -77,6 +97,7 @@ export const envVariables: EnvVariableModel[] = [ description: "Your site's meta description, important for SEO optimization.", category: 'Site Configuration', + required: true, validate: ({ value }) => { return z .string() @@ -133,6 +154,27 @@ export const envVariables: EnvVariableModel[] = [ 'Your Cloudflare Captcha secret token for backend verification.', category: 'Security', secret: true, + contextualValidation: { + dependencies: [ + { + variable: 'NEXT_PUBLIC_CAPTCHA_SITE_KEY', + condition: (value) => { + return value !== ''; + }, + message: + 'CAPTCHA_SECRET_TOKEN is required when NEXT_PUBLIC_CAPTCHA_SITE_KEY is set', + }, + ], + validate: ({ value }) => { + return z + .string() + .min( + 1, + `The CAPTCHA_SECRET_TOKEN variable must be at least 1 character`, + ) + .safeParse(value); + }, + }, validate: ({ value }) => { return z .string() @@ -286,6 +328,7 @@ export const envVariables: EnvVariableModel[] = [ name: 'NEXT_PUBLIC_SUPABASE_URL', description: 'Your Supabase project URL.', category: 'Supabase', + required: true, validate: ({ value, mode }) => { if (mode === 'development') { return z @@ -312,6 +355,7 @@ export const envVariables: EnvVariableModel[] = [ name: 'NEXT_PUBLIC_SUPABASE_ANON_KEY', description: 'Your Supabase anonymous API key.', category: 'Supabase', + required: true, validate: ({ value }) => { return z .string() @@ -319,7 +363,6 @@ export const envVariables: EnvVariableModel[] = [ 1, `The NEXT_PUBLIC_SUPABASE_ANON_KEY variable must be at least 1 character`, ) - .optional() .safeParse(value); }, }, @@ -328,6 +371,7 @@ export const envVariables: EnvVariableModel[] = [ description: 'Your Supabase service role key (keep this secret!).', category: 'Supabase', secret: true, + required: true, validate: ({ value, variables }) => { return z .string() @@ -335,7 +379,6 @@ export const envVariables: EnvVariableModel[] = [ 1, `The SUPABASE_SERVICE_ROLE_KEY variable must be at least 1 character`, ) - .optional() .refine( (value) => { return value !== variables['NEXT_PUBLIC_SUPABASE_ANON_KEY']; @@ -352,6 +395,7 @@ export const envVariables: EnvVariableModel[] = [ description: 'Secret key for Supabase webhook verification.', category: 'Supabase', secret: true, + required: true, validate: ({ value }) => { return z .string() @@ -368,6 +412,7 @@ export const envVariables: EnvVariableModel[] = [ description: 'Your chosen billing provider. Options: stripe or lemon-squeezy.', category: 'Billing', + required: true, validate: ({ value }) => { return z.enum(['stripe', 'lemon-squeezy']).optional().safeParse(value); }, @@ -376,6 +421,33 @@ export const envVariables: EnvVariableModel[] = [ name: 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY', description: 'Your Stripe publishable key.', category: 'Billing', + contextualValidation: { + dependencies: [ + { + variable: 'NEXT_PUBLIC_BILLING_PROVIDER', + condition: (value) => value === 'stripe', + message: + 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is required when NEXT_PUBLIC_BILLING_PROVIDER is set to "stripe"', + }, + ], + validate: ({ value }) => { + return z + .string() + .min( + 1, + `The NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY variable must be at least 1 character`, + ) + .refine( + (value) => { + return value.startsWith('pk_'); + }, + { + message: `The NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY variable must start with pk_`, + }, + ) + .safeParse(value); + }, + }, validate: ({ value }) => { return z .string() @@ -392,6 +464,30 @@ export const envVariables: EnvVariableModel[] = [ description: 'Your Stripe secret key.', category: 'Billing', secret: true, + contextualValidation: { + dependencies: [ + { + variable: 'NEXT_PUBLIC_BILLING_PROVIDER', + condition: (value) => value === 'stripe', + message: + 'STRIPE_SECRET_KEY is required when NEXT_PUBLIC_BILLING_PROVIDER is set to "stripe"', + }, + ], + validate: ({ value }) => { + return z + .string() + .min(1, `The STRIPE_SECRET_KEY variable must be at least 1 character`) + .refine( + (value) => { + return value.startsWith('sk_') || value.startsWith('rk_'); + }, + { + message: `The STRIPE_SECRET_KEY variable must start with sk_ or rk_`, + }, + ) + .safeParse(value); + }, + }, validate: ({ value }) => { return z .string() @@ -405,6 +501,33 @@ export const envVariables: EnvVariableModel[] = [ description: 'Your Stripe webhook secret.', category: 'Billing', secret: true, + contextualValidation: { + dependencies: [ + { + variable: 'NEXT_PUBLIC_BILLING_PROVIDER', + condition: (value) => value === 'stripe', + message: + 'STRIPE_WEBHOOK_SECRET is required when NEXT_PUBLIC_BILLING_PROVIDER is set to "stripe"', + }, + ], + validate: ({ value }) => { + return z + .string() + .min( + 1, + `The STRIPE_WEBHOOK_SECRET variable must be at least 1 character`, + ) + .refine( + (value) => { + return value.startsWith('whsec_'); + }, + { + message: `The STRIPE_WEBHOOK_SECRET variable must start with whsec_`, + }, + ) + .safeParse(value); + }, + }, validate: ({ value }) => { return z .string() @@ -421,6 +544,25 @@ export const envVariables: EnvVariableModel[] = [ description: 'Your Lemon Squeezy secret key.', category: 'Billing', secret: true, + contextualValidation: { + dependencies: [ + { + variable: 'NEXT_PUBLIC_BILLING_PROVIDER', + condition: (value) => value === 'lemon-squeezy', + message: + 'LEMON_SQUEEZY_SECRET_KEY is required when NEXT_PUBLIC_BILLING_PROVIDER is set to "lemon-squeezy"', + }, + ], + validate: ({ value }) => { + return z + .string() + .min( + 1, + `The LEMON_SQUEEZY_SECRET_KEY variable must be at least 1 character`, + ) + .safeParse(value); + }, + }, validate: ({ value }) => { return z .string() @@ -436,6 +578,25 @@ export const envVariables: EnvVariableModel[] = [ name: 'LEMON_SQUEEZY_STORE_ID', description: 'Your Lemon Squeezy store ID.', category: 'Billing', + contextualValidation: { + dependencies: [ + { + variable: 'NEXT_PUBLIC_BILLING_PROVIDER', + condition: (value) => value === 'lemon-squeezy', + message: + 'LEMON_SQUEEZY_STORE_ID is required when NEXT_PUBLIC_BILLING_PROVIDER is set to "lemon-squeezy"', + }, + ], + validate: ({ value }) => { + return z + .string() + .min( + 1, + `The LEMON_SQUEEZY_STORE_ID variable must be at least 1 character`, + ) + .safeParse(value); + }, + }, validate: ({ value }) => { return z .string() @@ -452,6 +613,25 @@ export const envVariables: EnvVariableModel[] = [ description: 'Your Lemon Squeezy signing secret.', category: 'Billing', secret: true, + contextualValidation: { + dependencies: [ + { + variable: 'NEXT_PUBLIC_BILLING_PROVIDER', + condition: (value) => value === 'lemon-squeezy', + message: + 'LEMON_SQUEEZY_SIGNING_SECRET is required when NEXT_PUBLIC_BILLING_PROVIDER is set to "lemon-squeezy"', + }, + ], + validate: ({ value }) => { + return z + .string() + .min( + 1, + `The LEMON_SQUEEZY_SIGNING_SECRET variable must be at least 1 character`, + ) + .safeParse(value); + }, + }, validate: ({ value }) => { return z .string() @@ -467,14 +647,16 @@ export const envVariables: EnvVariableModel[] = [ name: 'MAILER_PROVIDER', description: 'Your email service provider. Options: nodemailer or resend.', category: 'Email', + required: true, validate: ({ value }) => { - return z.enum(['nodemailer', 'resend']).optional().safeParse(value); + return z.enum(['nodemailer', 'resend']).safeParse(value); }, }, { name: 'EMAIL_SENDER', description: 'Default sender email address.', category: 'Email', + required: true, validate: ({ value }) => { return z .string() @@ -486,6 +668,7 @@ export const envVariables: EnvVariableModel[] = [ name: 'CONTACT_EMAIL', description: 'Email address for contact form submissions.', category: 'Email', + required: true, validate: ({ value }) => { return z .string() @@ -499,42 +682,99 @@ export const envVariables: EnvVariableModel[] = [ description: 'Your Resend API key.', category: 'Email', secret: true, - validate: ({ value }) => { - return z - .string() - .min(1, `The RESEND_API_KEY variable must be at least 1 character`) - .safeParse(value); + contextualValidation: { + dependencies: [ + { + variable: 'MAILER_PROVIDER', + condition: (value) => value === 'resend', + message: + 'RESEND_API_KEY is required when MAILER_PROVIDER is set to "resend"', + }, + ], + validate: ({ value, variables }) => { + if (variables['MAILER_PROVIDER'] === 'resend') { + return z + .string() + .min(1, `The RESEND_API_KEY variable must be at least 1 character`) + .safeParse(value); + } + + return z.string().optional().safeParse(value); + }, }, }, { name: 'EMAIL_HOST', description: 'SMTP host for Nodemailer configuration.', category: 'Email', - validate: ({ value }) => { - return z.string().safeParse(value); + contextualValidation: { + dependencies: [ + { + variable: 'MAILER_PROVIDER', + condition: (value) => value === 'nodemailer', + message: + 'EMAIL_HOST is required when MAILER_PROVIDER is set to "nodemailer"', + }, + ], + validate: ({ value, variables }) => { + if (variables['MAILER_PROVIDER'] === 'nodemailer') { + return z + .string() + .min(1, 'The EMAIL_HOST variable must be at least 1 character') + .safeParse(value); + } + return z.string().optional().safeParse(value); + }, }, }, { name: 'EMAIL_PORT', description: 'SMTP port for Nodemailer configuration.', category: 'Email', - validate: ({ value }) => { - return z.coerce - .number() - .min(1, `The EMAIL_PORT variable must be at least 1 character`) - .max(65535, `The EMAIL_PORT variable must be at most 65535`) - .safeParse(value); + contextualValidation: { + dependencies: [ + { + variable: 'MAILER_PROVIDER', + condition: (value) => value === 'nodemailer', + message: + 'EMAIL_PORT is required when MAILER_PROVIDER is set to "nodemailer"', + }, + ], + validate: ({ value, variables }) => { + if (variables['MAILER_PROVIDER'] === 'nodemailer') { + return z.coerce + .number() + .min(1, 'The EMAIL_PORT variable must be at least 1') + .max(65535, 'The EMAIL_PORT variable must be at most 65535') + .safeParse(value); + } + return z.coerce.number().optional().safeParse(value); + }, }, }, { name: 'EMAIL_USER', description: 'SMTP user for Nodemailer configuration.', category: 'Email', - validate: ({ value }) => { - return z - .string() - .min(1, `The EMAIL_USER variable must be at least 1 character`) - .safeParse(value); + contextualValidation: { + dependencies: [ + { + variable: 'MAILER_PROVIDER', + condition: (value) => value === 'nodemailer', + message: + 'EMAIL_USER is required when MAILER_PROVIDER is set to "nodemailer"', + }, + ], + validate: ({ value, variables }) => { + if (variables['MAILER_PROVIDER'] === 'nodemailer') { + return z + .string() + .min(1, 'The EMAIL_USER variable must be at least 1 character') + .safeParse(value); + } + + return z.string().optional().safeParse(value); + }, }, }, { @@ -542,17 +782,46 @@ export const envVariables: EnvVariableModel[] = [ description: 'SMTP password for Nodemailer configuration.', category: 'Email', secret: true, - validate: ({ value }) => { - return z - .string() - .min(1, `The EMAIL_PASSWORD variable must be at least 1 character`) - .safeParse(value); + contextualValidation: { + dependencies: [ + { + variable: 'MAILER_PROVIDER', + condition: (value) => value === 'nodemailer', + message: + 'EMAIL_PASSWORD is required when MAILER_PROVIDER is set to "nodemailer"', + }, + ], + validate: ({ value, variables }) => { + if (variables['MAILER_PROVIDER'] === 'nodemailer') { + return z + .string() + .min(1, 'The EMAIL_PASSWORD variable must be at least 1 character') + .safeParse(value); + } + return z.string().optional().safeParse(value); + }, }, }, { name: 'EMAIL_TLS', description: 'Whether to use TLS for SMTP connection.', category: 'Email', + contextualValidation: { + dependencies: [ + { + variable: 'MAILER_PROVIDER', + condition: (value) => value === 'nodemailer', + message: + 'EMAIL_TLS is required when MAILER_PROVIDER is set to "nodemailer"', + }, + ], + validate: ({ value, variables }) => { + if (variables['MAILER_PROVIDER'] === 'nodemailer') { + return z.coerce.boolean().optional().safeParse(value); + } + return z.coerce.boolean().optional().safeParse(value); + }, + }, validate: ({ value }) => { return z.coerce.boolean().optional().safeParse(value); }, @@ -569,23 +838,50 @@ export const envVariables: EnvVariableModel[] = [ name: 'NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND', description: 'Your Keystatic storage kind. Options: local, cloud, github.', category: 'CMS', - validate: ({ value }) => { - return z.enum(['local', 'cloud', 'github']).optional().safeParse(value); + contextualValidation: { + dependencies: [ + { + variable: 'CMS_CLIENT', + condition: (value) => value === 'keystatic', + message: + 'NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND is required when CMS_CLIENT is set to "keystatic"', + }, + ], + validate: ({ value, variables }) => { + if (variables['CMS_CLIENT'] === 'keystatic') { + return z + .enum(['local', 'cloud', 'github']) + .optional() + .safeParse(value); + } + return z.enum(['local', 'cloud', 'github']).optional().safeParse(value); + }, }, }, { name: 'NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO', description: 'Your Keystatic storage repo.', category: 'CMS', - validate: ({ value }) => { - return z - .string() - .min( - 1, - `The NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO variable must be at least 1 character`, - ) - .optional() - .safeParse(value); + contextualValidation: { + dependencies: [ + { + variable: 'CMS_CLIENT', + condition: (value, variables) => + value === 'keystatic' && + variables['NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND'] === 'github', + message: + 'NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO is required when CMS_CLIENT is set to "keystatic"', + }, + ], + validate: ({ value }) => { + return z + .string() + .min( + 1, + `The NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO variable must be at least 1 character`, + ) + .safeParse(value); + }, }, }, { @@ -593,58 +889,94 @@ export const envVariables: EnvVariableModel[] = [ description: 'Your Keystatic GitHub token.', category: 'CMS', secret: true, - validate: ({ value }) => { - return z - .string() - .min( - 1, - `The KEYSTATIC_GITHUB_TOKEN variable must be at least 1 character`, - ) - .optional() - .safeParse(value); + contextualValidation: { + dependencies: [ + { + variable: 'CMS_CLIENT', + condition: (value, variables) => + value === 'keystatic' && + variables['NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND'] === 'github', + message: + 'KEYSTATIC_GITHUB_TOKEN is required when CMS_CLIENT is set to "keystatic"', + }, + ], + validate: ({ value }) => { + return z + .string() + .min( + 1, + `The KEYSTATIC_GITHUB_TOKEN variable must be at least 1 character`, + ) + .safeParse(value); + }, }, }, { name: 'KEYSTATIC_PATH_PREFIX', description: 'Your Keystatic path prefix.', category: 'CMS', - validate: ({ value }) => { - return z - .string() - .min( - 1, - `The KEYSTATIC_PATH_PREFIX variable must be at least 1 character`, - ) - .optional() - .safeParse(value); + contextualValidation: { + dependencies: [ + { + variable: 'CMS_CLIENT', + condition: (value) => value === 'keystatic', + message: + 'KEYSTATIC_PATH_PREFIX is required when CMS_CLIENT is set to "keystatic"', + }, + ], + validate: ({ value }) => { + return z + .string() + .safeParse(value); + }, }, }, { name: 'NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH', description: 'Your Keystatic content path.', category: 'CMS', - validate: ({ value }) => { - return z - .string() - .min( - 1, - `The NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH variable must be at least 1 character`, - ) - .optional() - .safeParse(value); + contextualValidation: { + dependencies: [ + { + variable: 'CMS_CLIENT', + condition: (value) => value === 'keystatic', + message: + 'NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH is required when CMS_CLIENT is set to "keystatic"', + }, + ], + validate: ({ value }) => { + return z + .string() + .min( + 1, + `The NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH variable must be at least 1 character`, + ) + .optional() + .safeParse(value); + }, }, }, { name: 'WORDPRESS_API_URL', description: 'WordPress API URL when using WordPress as CMS.', category: 'CMS', - validate: ({ value }) => { - return z - .string() - .url({ - message: `The WORDPRESS_API_URL variable must be a valid URL`, - }) - .safeParse(value); + contextualValidation: { + dependencies: [ + { + variable: 'CMS_CLIENT', + condition: (value) => value === 'wordpress', + message: + 'WORDPRESS_API_URL is required when CMS_CLIENT is set to "wordpress"', + }, + ], + validate: ({ value }) => { + return z + .string() + .url({ + message: `The WORDPRESS_API_URL variable must be a valid URL`, + }) + .safeParse(value); + }, }, }, { @@ -679,45 +1011,6 @@ export const envVariables: EnvVariableModel[] = [ return z.coerce.boolean().optional().safeParse(value); }, }, - { - name: `ENABLE_REACT_COMPILER`, - description: 'Enables the React compiler [experimental]', - category: 'Features', - validate: ({ value }) => { - return z.coerce.boolean().optional().safeParse(value); - }, - }, - { - name: 'NEXT_PUBLIC_MONITORING_PROVIDER', - description: 'The monitoring provider to use.', - category: 'Monitoring', - validate: ({ value }) => { - return z.enum(['baselime', 'sentry']).optional().safeParse(value); - }, - }, - { - name: 'NEXT_PUBLIC_BASELIME_KEY', - description: 'The Baselime key to use.', - category: 'Monitoring', - validate: ({ value }) => { - return z - .string() - .min( - 1, - `The NEXT_PUBLIC_BASELIME_KEY variable must be at least 1 character`, - ) - .optional() - .safeParse(value); - }, - }, - { - name: 'STRIPE_ENABLE_TRIAL_WITHOUT_CC', - description: 'Enables trial plans without credit card.', - category: 'Billing', - validate: ({ value }) => { - return z.coerce.boolean().optional().safeParse(value); - }, - }, { name: 'NEXT_PUBLIC_VERSION_UPDATER_REFETCH_INTERVAL_SECONDS', description: 'The interval in seconds to check for updates.', @@ -737,10 +1030,61 @@ export const envVariables: EnvVariableModel[] = [ .safeParse(value); }, }, + { + name: `ENABLE_REACT_COMPILER`, + description: 'Enables the React compiler [experimental]', + category: 'Build', + validate: ({ value }) => { + return z.coerce.boolean().optional().safeParse(value); + }, + }, + { + name: 'NEXT_PUBLIC_MONITORING_PROVIDER', + description: 'The monitoring provider to use.', + category: 'Monitoring', + required: true, + validate: ({ value }) => { + return z.enum(['baselime', 'sentry', '']).optional().safeParse(value); + }, + }, + { + name: 'NEXT_PUBLIC_BASELIME_KEY', + description: 'The Baselime key to use.', + category: 'Monitoring', + contextualValidation: { + dependencies: [ + { + variable: 'NEXT_PUBLIC_MONITORING_PROVIDER', + condition: (value) => value === 'baselime', + message: + 'NEXT_PUBLIC_BASELIME_KEY is required when NEXT_PUBLIC_MONITORING_PROVIDER is set to "baselime"', + }, + ], + validate: ({ value }) => { + return z + .string() + .min( + 1, + `The NEXT_PUBLIC_BASELIME_KEY variable must be at least 1 character`, + ) + .optional() + .safeParse(value); + }, + }, + }, + { + name: 'STRIPE_ENABLE_TRIAL_WITHOUT_CC', + description: 'Enables trial plans without credit card.', + category: 'Billing', + validate: ({ value }) => { + return z.coerce.boolean().optional().safeParse(value); + }, + }, { name: 'NEXT_PUBLIC_THEME_COLOR', description: 'The default theme color.', category: 'Theme', + required: true, validate: ({ value }) => { return z .string() @@ -756,6 +1100,7 @@ export const envVariables: EnvVariableModel[] = [ name: 'NEXT_PUBLIC_THEME_COLOR_DARK', description: 'The default theme color for dark mode.', category: 'Theme', + required: true, validate: ({ value }) => { return z .string() @@ -767,4 +1112,12 @@ export const envVariables: EnvVariableModel[] = [ .safeParse(value); }, }, + { + name: 'NEXT_PUBLIC_DISPLAY_TERMS_AND_CONDITIONS_CHECKBOX', + description: 'Whether to display the terms checkbox during sign-up.', + category: 'Features', + validate: ({ value }) => { + return z.coerce.boolean().optional().safeParse(value); + }, + }, ]; diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index fe1ebf677..34b6ec29d 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -115,6 +115,18 @@ async function adminMiddleware(request: NextRequest, response: NextResponse) { ); } + const supabase = createMiddlewareClient(request, response); + + const requiresMultiFactorAuthentication = + await checkRequiresMultiFactorAuthentication(supabase); + + // If user requires multi-factor authentication, redirect to MFA page. + if (requiresMultiFactorAuthentication) { + return NextResponse.redirect( + new URL(pathsConfig.auth.verifyMfa, origin).href, + ); + } + const role = user?.app_metadata.role; // If user is not an admin, redirect to 404 page. diff --git a/packages/features/admin/src/lib/server/utils/is-super-admin.ts b/packages/features/admin/src/lib/server/utils/is-super-admin.ts index 1b0deaec1..16b34589d 100644 --- a/packages/features/admin/src/lib/server/utils/is-super-admin.ts +++ b/packages/features/admin/src/lib/server/utils/is-super-admin.ts @@ -1,5 +1,6 @@ import { SupabaseClient } from '@supabase/supabase-js'; +import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa'; import { Database } from '@kit/supabase/database'; /** @@ -18,6 +19,14 @@ export async function isSuperAdmin(client: SupabaseClient) { return false; } + const requiresMultiFactorAuthentication = + await checkRequiresMultiFactorAuthentication(client); + + // If user requires multi-factor authentication, deny access. + if (requiresMultiFactorAuthentication) { + return false; + } + const appMetadata = data.user.app_metadata; return appMetadata?.role === 'super-admin';