Dev Tools improvements (#247)

* Refactor environment variables UI and update validation logic

Enhanced the environment variables page layout for better responsiveness and structure by introducing new components and styles. Added `EnvListDisplay` for grouped variable display and adjusted several UI elements for clarity and consistency. Updated `NEXT_PUBLIC_SENTRY_ENVIRONMENT` validation to make it optional, aligning with updated requirements.

* Add environment variable validation and enhance page headers

Introduces robust validation for environment variables, ensuring correctness and contextual dependency checks. Updates page headers with titles and detailed descriptions for better usability and clarity.

* Refactor variable page layout and improve code readability

Rearranged className attributes in JSX for consistency and readability. Refactored map and enum validation logic for better formatting and maintainability. Applied minor corrections to types and formatting in other components.

* Refactor styles and simplify component logic

Updated badge variants to streamline styles and removed redundant hover states. Simplified logic in email page by extracting breadcrumb values and optimizing title rendering. Adjusted environment variables manager layout for cleaner rendering and removed unnecessary elements.

* Add real-time translation updates with RxJS and UI improvements

Introduced a Subject with debounce mechanism for handling translation updates, enhancing real-time editing in the translations comparison module. Improved UI components, including conditional rendering, better input handling, and layout adjustments. Implemented a server action for updating translations and streamlined type definitions in the emails page.

* Enhance environment variable copying functionality and improve user feedback

Updated the environment variables manager to copy structured environment variable data to the clipboard, improving usability. Adjusted toast notifications to provide clearer success and error messages during the copy process. Additionally, fixed a minor issue in the translations comparison component by ensuring proper filtering of keys based on the search input.

* Add AI translation functionality and update dependencies

Implemented a new action for translating missing strings using AI, enhancing the translations comparison component. Introduced a loading state during translation and improved error handling for translation updates. Updated package dependencies, including the addition of '@ai-sdk/openai' and 'ai' to facilitate AI-driven translations. Enhanced UI components for better user experience and streamlined translation management.
This commit is contained in:
Giancarlo Buomprisco
2025-04-29 09:11:12 +07:00
committed by GitHub
parent cea46b06a1
commit 76bfeddd32
20 changed files with 1730 additions and 679 deletions

View File

@@ -152,11 +152,18 @@ export function processEnvDefinitions(
if (!variableMap[variable.key]) {
variableMap[variable.key] = {
key: variable.key,
isVisible: true,
definitions: [],
effectiveValue: variable.value,
effectiveSource: variable.source,
isOverridden: false,
category: model ? model.category : 'Custom',
validation: {
success: true,
error: {
issues: [],
},
},
};
}
@@ -212,6 +219,231 @@ export function processEnvDefinitions(
}
}
// after computing the effective values, we can check for errors
for (const key in variableMap) {
const model = envVariables.find((v) => key === v.name);
const varState = variableMap[key];
if (!varState) {
continue;
}
let validation: {
success: boolean;
error: {
issues: string[];
};
} = { success: true, error: { issues: [] } };
if (model) {
const allVariables = Object.values(variableMap).reduce(
(acc, variable) => {
return {
...acc,
[variable.key]: variable.effectiveValue,
};
},
{} as Record<string, string>,
);
// First check if it's required but missing
if (model.required && !varState.effectiveValue) {
validation = {
success: false,
error: {
issues: [
`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,
});
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)
.filter((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,
});
if (!result.success) {
validation = {
success: false,
error: {
issues: result.error.issues
.map((issue) => issue.message)
.filter((message) => !!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,
});
if (!result.success) {
validation = {
success: false,
error: {
issues: result.error.issues
.map((issue) => issue.message)
.filter((message) => !!message),
},
};
}
}
}
varState.validation = validation;
}
// Final pass: Validate missing variables that are marked as required
// or as having contextual validation
for (const model of envVariables) {
// If the variable exists in appState, use that
const existingVar = variableMap[model.name];
if (existingVar) {
// If the variable is already in the map, skip it
continue;
}
if (model.required || model.contextualValidation) {
if (model.contextualValidation) {
const allVariables = Object.values(variableMap).reduce(
(acc, variable) => {
return {
...acc,
[variable.key]: variable.effectiveValue,
};
},
{} as Record<string, string>,
);
const errors =
model?.contextualValidation?.dependencies
.map((dep) => {
const dependencyValue = allVariables[dep.variable] ?? '';
const shouldValidate = dep.condition(
dependencyValue,
allVariables,
);
if (!shouldValidate) {
return [];
}
const effectiveValue = allVariables[dep.variable] ?? '';
const validation = model.contextualValidation!.validate({
value: effectiveValue,
variables: allVariables,
mode,
});
if (validation) {
return [dep.message];
}
return [];
})
.flat() ?? ([] as string[]);
if (errors.length === 0) {
continue;
} else {
variableMap[model.name] = {
key: model.name,
effectiveValue: '',
effectiveSource: 'MISSING',
isVisible: true,
category: model.category,
isOverridden: false,
definitions: [],
validation: {
success: false,
error: {
issues: errors.map((error) => error),
},
},
};
}
}
// If it doesn't exist but is required or has contextual validation, create an empty state
variableMap[model.name] = {
key: model.name,
effectiveValue: '',
effectiveSource: 'MISSING',
isVisible: true,
category: model.category,
isOverridden: false,
definitions: [],
validation: {
success: false,
error: {
issues: [
`This variable is required but missing from your environment files`,
],
},
},
};
}
}
return {
appName: envInfo.appName,
filePath: envInfo.filePath,
@@ -227,11 +459,6 @@ export async function getEnvState(
return envInfos.map((info) => processEnvDefinitions(info, options.mode));
}
// Utility function to get list of env files for current mode
export function getEnvFilesForMode(mode: EnvMode): string[] {
return ENV_FILE_PRECEDENCE[mode];
}
export async function getVariable(key: string, mode: EnvMode) {
// Get the processed environment state for all apps (you can limit to 'web' via options)
const envStates = await getEnvState({ mode, apps: ['web'] });

View File

@@ -1,34 +1,51 @@
import { EnvMode } from '@/app/variables/lib/types';
import { z } from 'zod';
type DependencyRule = {
variable: string;
condition: (value: string, variables: Record<string, string>) => boolean;
message: string;
};
type ModelType =
| 'string'
| 'longString'
| 'number'
| 'boolean'
| 'enum'
| 'url'
| 'email';
type ContextualValidation = {
dependencies: DependencyRule[];
validate: (props: {
value: string;
variables: Record<string, string>;
mode: EnvMode;
}) => z.SafeParseReturnType<unknown, unknown>;
};
type Values = Array<string | null>;
export type EnvVariableModel = {
name: string;
description: string;
hint?: string;
secret?: boolean;
required?: boolean;
type?: ModelType;
values?: Values;
category: string;
test?: (value: string) => Promise<boolean>;
validate?: (props: {
required?: boolean;
validate?: ({
value,
variables,
mode,
}: {
value: string;
variables: Record<string, string>;
mode: EnvMode;
}) => z.SafeParseReturnType<unknown, unknown>;
contextualValidation?: ContextualValidation;
contextualValidation?: {
dependencies: Array<{
variable: string;
condition: (value: string, variables: Record<string, string>) => boolean;
message: string;
}>;
validate: ({
value,
variables,
mode,
}: {
value: string;
variables: Record<string, string>;
mode: EnvMode;
}) => z.SafeParseReturnType<unknown, unknown>;
};
};
export const envVariables: EnvVariableModel[] = [
@@ -38,6 +55,8 @@ export const envVariables: EnvVariableModel[] = [
'The URL of your site, used for generating absolute URLs. Must include the protocol.',
category: 'Site Configuration',
required: true,
type: 'url',
hint: `Ex. https://example.com`,
validate: ({ value, mode }) => {
if (mode === 'development') {
return z
@@ -65,7 +84,9 @@ export const envVariables: EnvVariableModel[] = [
description:
"Your product's name, used consistently across the application interface.",
category: 'Site Configuration',
hint: `Ex. "My Product"`,
required: true,
type: 'string',
validate: ({ value }) => {
return z
.string()
@@ -82,6 +103,8 @@ export const envVariables: EnvVariableModel[] = [
"The site's title tag content, crucial for SEO and browser display.",
category: 'Site Configuration',
required: true,
hint: `Ex. "My Product, the best product ever"`,
type: 'string',
validate: ({ value }) => {
return z
.string()
@@ -94,6 +117,7 @@ export const envVariables: EnvVariableModel[] = [
},
{
name: 'NEXT_PUBLIC_SITE_DESCRIPTION',
type: 'longString',
description:
"Your site's meta description, important for SEO optimization.",
category: 'Site Configuration',
@@ -111,8 +135,10 @@ export const envVariables: EnvVariableModel[] = [
},
{
name: 'NEXT_PUBLIC_DEFAULT_LOCALE',
type: 'string',
description: 'Sets the default language for your application.',
category: 'Localization',
hint: `Ex. "en"`,
validate: ({ value }) => {
return z
.string()
@@ -128,6 +154,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_AUTH_PASSWORD',
description: 'Enables or disables password-based authentication.',
category: 'Authentication',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -136,6 +163,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_AUTH_MAGIC_LINK',
description: 'Enables or disables magic link authentication.',
category: 'Authentication',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -144,6 +172,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_CAPTCHA_SITE_KEY',
description: 'Your Cloudflare Captcha site key for form protection.',
category: 'Security',
type: 'string',
validate: ({ value }) => {
return z.string().optional().safeParse(value);
},
@@ -154,6 +183,7 @@ export const envVariables: EnvVariableModel[] = [
'Your Cloudflare Captcha secret token for backend verification.',
category: 'Security',
secret: true,
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -191,6 +221,8 @@ export const envVariables: EnvVariableModel[] = [
description:
'Controls user navigation layout. Options: sidebar, header, or custom.',
category: 'Navigation',
type: 'enum',
values: ['sidebar', 'header', 'custom'],
validate: ({ value }) => {
return z
.enum(['sidebar', 'header', 'custom'])
@@ -202,6 +234,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_HOME_SIDEBAR_COLLAPSED',
description: 'Sets the default state of the home sidebar.',
category: 'Navigation',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -211,6 +244,8 @@ export const envVariables: EnvVariableModel[] = [
description:
'Controls team navigation layout. Options: sidebar, header, or custom.',
category: 'Navigation',
type: 'enum',
values: ['sidebar', 'header', 'custom'],
validate: ({ value }) => {
return z
.enum(['sidebar', 'header', 'custom'])
@@ -222,6 +257,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_TEAM_SIDEBAR_COLLAPSED',
description: 'Sets the default state of the team sidebar.',
category: 'Navigation',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -231,6 +267,8 @@ export const envVariables: EnvVariableModel[] = [
description:
'Defines sidebar collapse behavior. Options: offscreen, icon, or none.',
category: 'Navigation',
type: 'enum',
values: ['offscreen', 'icon', 'none'],
validate: ({ value }) => {
return z.enum(['offscreen', 'icon', 'none']).optional().safeParse(value);
},
@@ -240,6 +278,8 @@ export const envVariables: EnvVariableModel[] = [
description:
'Controls the default theme appearance. Options: light, dark, or system.',
category: 'Theme',
type: 'enum',
values: ['light', 'dark', 'system'],
validate: ({ value }) => {
return z.enum(['light', 'dark', 'system']).optional().safeParse(value);
},
@@ -248,6 +288,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_ENABLE_THEME_TOGGLE',
description: 'Controls visibility of the theme toggle feature.',
category: 'Theme',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -256,6 +297,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_ENABLE_SIDEBAR_TRIGGER',
description: 'Controls visibility of the sidebar trigger feature.',
category: 'Navigation',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -264,6 +306,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION',
description: 'Allows users to delete their personal accounts.',
category: 'Features',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -272,6 +315,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING',
description: 'Enables billing features for personal accounts.',
category: 'Features',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -280,6 +324,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS',
description: 'Master switch for team account functionality.',
category: 'Features',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -288,6 +333,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION',
description: 'Controls ability to create new team accounts.',
category: 'Features',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -296,6 +342,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION',
description: 'Allows team account deletion.',
category: 'Features',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -304,6 +351,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING',
description: 'Enables billing features for team accounts.',
category: 'Features',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -312,6 +360,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_ENABLE_NOTIFICATIONS',
description: 'Controls the notification system.',
category: 'Notifications',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -320,6 +369,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_REALTIME_NOTIFICATIONS',
description: 'Enables real-time notifications using Supabase Realtime.',
category: 'Notifications',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -328,7 +378,9 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_SUPABASE_URL',
description: 'Your Supabase project URL.',
category: 'Supabase',
hint: `Ex. https://your-project.supabase.co`,
required: true,
type: 'url',
validate: ({ value, mode }) => {
if (mode === 'development') {
return z
@@ -356,6 +408,7 @@ export const envVariables: EnvVariableModel[] = [
description: 'Your Supabase anonymous API key.',
category: 'Supabase',
required: true,
type: 'string',
validate: ({ value }) => {
return z
.string()
@@ -372,6 +425,7 @@ export const envVariables: EnvVariableModel[] = [
category: 'Supabase',
secret: true,
required: true,
type: 'string',
validate: ({ value, variables }) => {
return z
.string()
@@ -396,6 +450,7 @@ export const envVariables: EnvVariableModel[] = [
category: 'Supabase',
secret: true,
required: true,
type: 'string',
validate: ({ value }) => {
return z
.string()
@@ -413,6 +468,8 @@ export const envVariables: EnvVariableModel[] = [
'Your chosen billing provider. Options: stripe or lemon-squeezy.',
category: 'Billing',
required: true,
type: 'enum',
values: ['stripe', 'lemon-squeezy'],
validate: ({ value }) => {
return z.enum(['stripe', 'lemon-squeezy']).optional().safeParse(value);
},
@@ -420,7 +477,9 @@ export const envVariables: EnvVariableModel[] = [
{
name: 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY',
description: 'Your Stripe publishable key.',
hint: `Ex. pk_test_123456789012345678901234`,
category: 'Billing',
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -463,7 +522,9 @@ export const envVariables: EnvVariableModel[] = [
name: 'STRIPE_SECRET_KEY',
description: 'Your Stripe secret key.',
category: 'Billing',
hint: `Ex. sk_test_123456789012345678901234`,
secret: true,
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -500,7 +561,9 @@ export const envVariables: EnvVariableModel[] = [
name: 'STRIPE_WEBHOOK_SECRET',
description: 'Your Stripe webhook secret.',
category: 'Billing',
hint: `Ex. whsec_123456789012345678901234`,
secret: true,
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -544,6 +607,7 @@ export const envVariables: EnvVariableModel[] = [
description: 'Your Lemon Squeezy secret key.',
category: 'Billing',
secret: true,
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -578,6 +642,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'LEMON_SQUEEZY_STORE_ID',
description: 'Your Lemon Squeezy store ID.',
category: 'Billing',
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -613,6 +678,7 @@ export const envVariables: EnvVariableModel[] = [
description: 'Your Lemon Squeezy signing secret.',
category: 'Billing',
secret: true,
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -648,6 +714,8 @@ export const envVariables: EnvVariableModel[] = [
description: 'Your email service provider. Options: nodemailer or resend.',
category: 'Email',
required: true,
type: 'enum',
values: ['nodemailer', 'resend'],
validate: ({ value }) => {
return z.enum(['nodemailer', 'resend']).safeParse(value);
},
@@ -656,7 +724,9 @@ export const envVariables: EnvVariableModel[] = [
name: 'EMAIL_SENDER',
description: 'Default sender email address.',
category: 'Email',
hint: `Ex. "Makerkit <admin@makerkit.dev>"`,
required: true,
type: 'string',
validate: ({ value }) => {
return z
.string()
@@ -668,7 +738,9 @@ export const envVariables: EnvVariableModel[] = [
name: 'CONTACT_EMAIL',
description: 'Email address for contact form submissions.',
category: 'Email',
hint: `Ex. "Makerkit <admin@makerkit.dev>"`,
required: true,
type: 'email',
validate: ({ value }) => {
return z
.string()
@@ -682,6 +754,7 @@ export const envVariables: EnvVariableModel[] = [
description: 'Your Resend API key.',
category: 'Email',
secret: true,
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -707,6 +780,8 @@ export const envVariables: EnvVariableModel[] = [
name: 'EMAIL_HOST',
description: 'SMTP host for Nodemailer configuration.',
category: 'Email',
type: 'string',
hint: `Ex. "smtp.example.com"`,
contextualValidation: {
dependencies: [
{
@@ -731,6 +806,8 @@ export const envVariables: EnvVariableModel[] = [
name: 'EMAIL_PORT',
description: 'SMTP port for Nodemailer configuration.',
category: 'Email',
type: 'number',
hint: `Ex. 587 or 465`,
contextualValidation: {
dependencies: [
{
@@ -756,6 +833,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'EMAIL_USER',
description: 'SMTP user for Nodemailer configuration.',
category: 'Email',
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -782,6 +860,7 @@ export const envVariables: EnvVariableModel[] = [
description: 'SMTP password for Nodemailer configuration.',
category: 'Email',
secret: true,
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -804,8 +883,9 @@ export const envVariables: EnvVariableModel[] = [
},
{
name: 'EMAIL_TLS',
description: 'Whether to use TLS for SMTP connection.',
description: 'Whether to use TLS for SMTP connection. Please check this in your SMTP provider settings.',
category: 'Email',
type: 'boolean',
contextualValidation: {
dependencies: [
{
@@ -830,6 +910,8 @@ export const envVariables: EnvVariableModel[] = [
name: 'CMS_CLIENT',
description: 'Your chosen CMS system. Options: wordpress or keystatic.',
category: 'CMS',
type: 'enum',
values: ['wordpress', 'keystatic'],
validate: ({ value }) => {
return z.enum(['wordpress', 'keystatic']).optional().safeParse(value);
},
@@ -838,6 +920,8 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND',
description: 'Your Keystatic storage kind. Options: local, cloud, github.',
category: 'CMS',
type: 'enum',
values: ['local', 'cloud', 'github'],
contextualValidation: {
dependencies: [
{
@@ -862,6 +946,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO',
description: 'Your Keystatic storage repo.',
category: 'CMS',
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -889,6 +974,7 @@ export const envVariables: EnvVariableModel[] = [
description: 'Your Keystatic GitHub token.',
category: 'CMS',
secret: true,
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -915,6 +1001,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'KEYSTATIC_PATH_PREFIX',
description: 'Your Keystatic path prefix.',
category: 'CMS',
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -933,6 +1020,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH',
description: 'Your Keystatic content path.',
category: 'CMS',
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -958,6 +1046,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'WORDPRESS_API_URL',
description: 'WordPress API URL when using WordPress as CMS.',
category: 'CMS',
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -981,6 +1070,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_LOCALES_PATH',
description: 'The path to your locales folder.',
category: 'Localization',
type: 'string',
validate: ({ value }) => {
return z
.string()
@@ -996,6 +1086,8 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_LANGUAGE_PRIORITY',
description: 'The priority setting as to how infer the language.',
category: 'Localization',
type: 'enum',
values: ['user', 'application'],
validate: ({ value }) => {
return z.enum(['user', 'application']).optional().safeParse(value);
},
@@ -1005,6 +1097,7 @@ export const envVariables: EnvVariableModel[] = [
description:
'Enables the version updater to poll the latest version and notify the user.',
category: 'Features',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -1013,6 +1106,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_VERSION_UPDATER_REFETCH_INTERVAL_SECONDS',
description: 'The interval in seconds to check for updates.',
category: 'Features',
type: 'number',
validate: ({ value }) => {
return z.coerce
.number()
@@ -1032,6 +1126,7 @@ export const envVariables: EnvVariableModel[] = [
name: `ENABLE_REACT_COMPILER`,
description: 'Enables the React compiler [experimental]',
category: 'Build',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -1040,7 +1135,8 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_MONITORING_PROVIDER',
description: 'The monitoring provider to use.',
category: 'Monitoring',
required: true,
type: 'enum',
values: ['baselime', 'sentry', 'none'],
validate: ({ value }) => {
return z.enum(['baselime', 'sentry', '']).optional().safeParse(value);
},
@@ -1049,6 +1145,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_SENTRY_DSN',
description: 'The Sentry DSN to use.',
category: 'Monitoring',
type: `string`,
contextualValidation: {
dependencies: [
{
@@ -1073,7 +1170,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_SENTRY_ENVIRONMENT',
description: 'The Sentry environment to use.',
category: 'Monitoring',
required: true,
type: 'string',
validate: ({ value }) => {
return z.string().optional().safeParse(value);
},
@@ -1082,6 +1179,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_BASELIME_KEY',
description: 'The Baselime key to use.',
category: 'Monitoring',
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -1107,6 +1205,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'STRIPE_ENABLE_TRIAL_WITHOUT_CC',
description: 'Enables trial plans without credit card.',
category: 'Billing',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -1115,6 +1214,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_THEME_COLOR',
description: 'The default theme color.',
category: 'Theme',
type: 'string',
required: true,
validate: ({ value }) => {
return z
@@ -1132,6 +1232,7 @@ export const envVariables: EnvVariableModel[] = [
description: 'The default theme color for dark mode.',
category: 'Theme',
required: true,
type: 'string',
validate: ({ value }) => {
return z
.string()
@@ -1147,6 +1248,17 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_DISPLAY_TERMS_AND_CONDITIONS_CHECKBOX',
description: 'Whether to display the terms checkbox during sign-up.',
category: 'Features',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
},
{
name: 'ENABLE_STRICT_CSP',
description: 'Enables strict Content Security Policy (CSP) headers.',
category: 'Security',
required: false,
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},

View File

@@ -0,0 +1,84 @@
'use server';
import { revalidatePath } from 'next/cache';
import { envVariables } from '@/app/variables/lib/env-variables-model';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:url';
import { z } from 'zod';
const Schema = z.object({
name: z.string().min(1),
value: z.string(),
mode: z.enum(['development', 'production']),
});
/**
* Update the environment variable in the specified file.
* @param props
*/
export async function updateEnvironmentVariableAction(
props: z.infer<typeof Schema>,
) {
// Validate the input
const { name, mode, value } = Schema.parse(props);
const root = resolve(process.cwd(), '..');
const model = envVariables.find((item) => item.name === name);
// Determine the source file based on the mode
const source = (() => {
const isSecret = model?.secret ?? true;
switch (mode) {
case 'development':
if (isSecret) {
return '.env.local';
} else {
return '.env.development';
}
case 'production':
if (isSecret) {
return '.env.production.local';
} else {
return '.env.production';
}
default:
throw new Error(`Invalid mode: ${mode}`);
}
})();
// check file exists, if not, create it
const filePath = `${root}/apps/web/${source}`;
if (!existsSync(filePath)) {
writeFileSync(filePath, '', 'utf-8');
}
const sourceEnvFile = readFileSync(`${root}apps/web/${source}`, 'utf-8');
let updatedEnvFile = '';
const isInSourceFile = sourceEnvFile.includes(name);
const isCommentedOut = sourceEnvFile.includes(`#${name}=`);
if (isInSourceFile && !isCommentedOut) {
updatedEnvFile = sourceEnvFile.replace(
new RegExp(`^${name}=.*`, 'm'),
`${name}=${value}`,
);
} else {
// if the key does not exist, append it to the end of the file
updatedEnvFile = `${sourceEnvFile}\n${name}=${value}`;
}
// write the updated content back to the file
writeFileSync(`${root}/apps/web/${source}`, updatedEnvFile, 'utf-8');
revalidatePath(`/variables`);
return {
success: true,
message: `Updated ${name} in "${source}"`,
};
}

View File

@@ -19,6 +19,13 @@ export type EnvVariableState = {
effectiveValue: string;
isOverridden: boolean;
effectiveSource: string;
isVisible: boolean;
validation: {
success: boolean;
error: {
issues: string[];
};
};
};
export type AppEnvState = {