2.3.0 Dev Tools (#180)

* 2.3.0 - Added new Dev Tools app
This commit is contained in:
Giancarlo Buomprisco
2025-02-21 13:29:42 +07:00
committed by GitHub
parent 59dfc0ad91
commit c185bcfa11
36 changed files with 3747 additions and 67 deletions

View File

@@ -0,0 +1,199 @@
'use client';
import Link from 'next/link';
import { EmailTesterFormSchema } from '@/app/emails/lib/email-tester-form-schema';
import { sendEmailAction } from '@/app/emails/lib/server-actions';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { Switch } from '@kit/ui/switch';
export function EmailTesterForm(props: {
template: string;
settings: {
username: string;
password: string;
sender: string;
host: string;
port: number;
tls: boolean;
};
}) {
const form = useForm({
resolver: zodResolver(EmailTesterFormSchema),
defaultValues: {
username: props.settings.username,
password: props.settings.password,
sender: props.settings.sender,
host: props.settings.host,
port: props.settings.port,
tls: props.settings.tls,
to: '',
},
});
return (
<div className={'flex flex-col space-y-8'}>
<p className="text-muted-foreground text-sm">
The settings below were filled from your environment variables. You can
change them to test different scenarios.{' '}
<Link className={'underline'} href={'https://www.nodemailer.com'}>
Learn more about Nodemailer if you're not sure how to configure it.
</Link>
</p>
<Form {...form}>
<form
className="flex flex-col space-y-6"
onSubmit={form.handleSubmit(async (data) => {
const promise = sendEmailAction({
template: props.template,
settings: {
username: data.username,
password: data.password,
sender: data.sender,
host: data.host,
port: data.port,
tls: data.tls,
to: data.to,
},
});
toast.promise(promise, {
loading: 'Sending email...',
success: 'Email sent successfully',
error: 'Failed to send email',
});
})}
>
<div className={'flex items-center space-x-2'}>
<FormField
name={'sender'}
render={({ field }) => {
return (
<FormItem className={'w-full'}>
<FormLabel>Sender</FormLabel>
<FormControl>
<Input {...field} placeholder={'Sender'} />
</FormControl>
</FormItem>
);
}}
/>
<FormField
name={'to'}
render={({ field }) => {
return (
<FormItem className={'w-full'}>
<FormLabel>Recipient</FormLabel>
<FormControl>
<Input {...field} type={'email'} placeholder={'to'} />
</FormControl>
</FormItem>
);
}}
/>
</div>
<div className={'flex items-center space-x-2'}>
<FormField
name={'username'}
render={({ field }) => {
return (
<FormItem className={'w-full'}>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} placeholder={'Username'} />
</FormControl>
</FormItem>
);
}}
/>
<FormField
name={'password'}
render={({ field }) => {
return (
<FormItem className={'w-full'}>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
{...field}
className={'w-full'}
placeholder={'Password'}
type={'password'}
/>
</FormControl>
</FormItem>
);
}}
/>
</div>
<div className={'flex items-center space-x-2'}>
<FormField
name={'host'}
render={({ field }) => {
return (
<FormItem className={'w-full'}>
<FormLabel>Host</FormLabel>
<FormControl>
<Input {...field} placeholder={'Host'} />
</FormControl>
</FormItem>
);
}}
/>
<FormField
name={'port'}
render={({ field }) => {
return (
<FormItem className={'w-full'}>
<FormLabel>Port</FormLabel>
<FormControl>
<Input {...field} placeholder={'Port'} />
</FormControl>
</FormItem>
);
}}
/>
<FormField
name={'tls'}
render={({ field }) => {
return (
<FormItem className={'w-full'}>
<FormLabel>Secure (TLS)</FormLabel>
<FormControl>
<Switch
{...field}
onCheckedChange={(value) => {
form.setValue('tls', value);
}}
/>
</FormControl>
</FormItem>
);
}}
/>
</div>
<Button type={'submit'}>Send Test Email</Button>
</form>
</Form>
</div>
);
}

View File

@@ -0,0 +1,104 @@
import { EmailTesterForm } from '@/app/emails/[id]/components/email-tester-form';
import { loadEmailTemplate } from '@/app/emails/lib/email-loader';
import { getVariable } from '@/app/variables/lib/env-scanner';
import { EnvMode } from '@/app/variables/lib/types';
import { EnvModeSelector } from '@/components/env-mode-selector';
import { IFrame } from '@/components/iframe';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import { Page, PageBody, PageHeader } from '@kit/ui/page';
type EmailPageProps = React.PropsWithChildren<{
params: Promise<{
id: string;
}>;
searchParams: Promise<{ mode?: EnvMode }>;
}>;
export const metadata = {
title: 'Email Template',
};
export default async function EmailPage(props: EmailPageProps) {
const { id } = await props.params;
const mode = (await props.searchParams).mode ?? 'development';
const template = await loadEmailTemplate(id);
const emailSettings = await getEmailSettings(mode);
return (
<Page style={'custom'}>
<PageHeader
displaySidebarTrigger={false}
description={
<AppBreadcrumbs
values={{
emails: 'Emails',
'invite-email': 'Invite Email',
'account-delete-email': 'Account Delete Email',
'confirm-email': 'Confirm Email',
'change-email-address-email': 'Change Email Address Email',
'reset-password-email': 'Reset Password Email',
'magic-link-email': 'Magic Link Email',
}}
/>
}
>
<EnvModeSelector mode={mode} />
</PageHeader>
<PageBody className={'flex flex-1 flex-col gap-y-4'}>
<p className={'text-muted-foreground py-1 text-xs'}>
Remember that the below is an approximation of the email. Always test
it in your inbox.{' '}
<Dialog>
<DialogTrigger asChild>
<Button variant={'link'} className="p-0 underline">
Test Email
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Send Test Email</DialogTitle>
<EmailTesterForm settings={emailSettings} template={id} />
</DialogContent>
</Dialog>
</p>
<IFrame className={'flex flex-1 flex-col'}>
<div
className={'flex flex-1 flex-col'}
dangerouslySetInnerHTML={{ __html: template.html }}
/>
</IFrame>
</PageBody>
</Page>
);
}
async function getEmailSettings(mode: EnvMode) {
const sender = await getVariable('EMAIL_SENDER', mode);
const host = await getVariable('EMAIL_HOST', mode);
const port = await getVariable('EMAIL_PORT', mode);
const tls = await getVariable('EMAIL_TLS', mode);
const username = await getVariable('EMAIL_USER', mode);
const password = await getVariable('EMAIL_PASSWORD', mode);
return {
sender,
host,
port: Number.isNaN(Number(port)) ? 487 : Number(port),
tls: tls === 'true',
username,
password,
};
}

View File

@@ -0,0 +1,57 @@
import {
renderAccountDeleteEmail,
renderInviteEmail,
} from '@kit/email-templates';
export async function loadEmailTemplate(id: string) {
if (id === 'account-delete-email') {
return renderAccountDeleteEmail({
productName: 'Makerkit',
userDisplayName: 'Giancarlo',
});
}
if (id === 'invite-email') {
return renderInviteEmail({
teamName: 'Makerkit',
teamLogo:
'',
inviter: 'Giancarlo',
invitedUserEmail: 'test@makerkit.dev',
link: 'https://makerkit.dev',
productName: 'Makerkit',
});
}
if (id === 'magic-link-email') {
return loadFromFileSystem('magic-link');
}
if (id === 'reset-password-email') {
return loadFromFileSystem('reset-password');
}
if (id === 'change-email-address-email') {
return loadFromFileSystem('change-email-address');
}
if (id === 'confirm-email') {
return loadFromFileSystem('confirm-email');
}
throw new Error(`Email template not found: ${id}`);
}
async function loadFromFileSystem(fileName: string) {
const { readFileSync } = await import('node:fs');
const { join } = await import('node:path');
const filePath = join(
process.cwd(),
`../web/supabase/templates/${fileName}.html`,
);
return {
html: readFileSync(filePath, 'utf8'),
};
}

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
export const EmailTesterFormSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
sender: z.string().min(1),
to: z.string().email(),
host: z.string().min(1),
port: z.number().min(1),
tls: z.boolean(),
});

View File

@@ -0,0 +1,38 @@
'use server';
import { loadEmailTemplate } from '@/app/emails/lib/email-loader';
export async function sendEmailAction(params: {
template: string;
settings: {
username: string;
password: string;
sender: string;
host: string;
to: string;
port: number;
tls: boolean;
};
}) {
const { settings } = params;
const { createTransport } = await import('nodemailer');
const transporter = createTransport({
host: settings.host,
port: settings.port,
secure: settings.tls,
auth: {
user: settings.username,
pass: settings.password,
},
});
const { html } = await loadEmailTemplate(params.template);
return transporter.sendMail({
html,
from: settings.sender,
to: settings.to,
subject: 'Test Email',
});
}

View File

@@ -0,0 +1,83 @@
import Link from 'next/link';
import {
CardButton,
CardButtonHeader,
CardButtonTitle,
} from '@kit/ui/card-button';
import { Heading } from '@kit/ui/heading';
import { Page, PageBody, PageHeader } from '@kit/ui/page';
export const metadata = {
title: 'Emails',
};
export default async function EmailsPage() {
return (
<Page style={'custom'}>
<PageHeader displaySidebarTrigger={false} description="Emails" />
<PageBody className={'gap-y-8'}>
<div className={'flex flex-col space-y-4'}>
<Heading level={5}>Supabase Auth Emails</Heading>
<div className={'grid grid-cols-1 gap-4 md:grid-cols-4'}>
<CardButton asChild>
<Link href={'/emails/confirm-email'}>
<CardButtonHeader>
<CardButtonTitle>Confirm Email</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
<CardButton asChild>
<Link href={'/emails/change-email-address-email'}>
<CardButtonHeader>
<CardButtonTitle>Change Email Address Email</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
<CardButton asChild>
<Link href={'/emails/reset-password-email'}>
<CardButtonHeader>
<CardButtonTitle>Reset Password Email</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
<CardButton asChild>
<Link href={'/emails/magic-link-email'}>
<CardButtonHeader>
<CardButtonTitle>Magic Link Email</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
</div>
</div>
<div className={'flex flex-col space-y-4'}>
<Heading level={5}>Transactional Emails</Heading>
<div className={'grid grid-cols-1 gap-4 md:grid-cols-4'}>
<CardButton asChild>
<Link href={'/emails/account-delete-email'}>
<CardButtonHeader>
<CardButtonTitle>Account Delete Email</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
<CardButton asChild>
<Link href={'/emails/invite-email'}>
<CardButtonHeader>
<CardButtonTitle>Invite Email</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
</div>
</div>
</PageBody>
</Page>
);
}

View File

@@ -0,0 +1,27 @@
import type { Metadata } from 'next';
import { DevToolLayout } from '@/components/app-layout';
import { RootProviders } from '@/components/root-providers';
import '../styles/globals.css';
export const metadata: Metadata = {
title: 'Makerkit | Dev Tool',
description: 'The dev tool for Makerkit',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<RootProviders>
<DevToolLayout>{children}</DevToolLayout>
</RootProviders>
</body>
</html>
);
}

View File

@@ -0,0 +1,213 @@
import { EnvMode } from '@/app/variables/lib/types';
import { getVariable } from '../variables/lib/env-scanner';
export function createConnectivityService(mode: EnvMode) {
return new ConnectivityService(mode);
}
class ConnectivityService {
constructor(private mode: EnvMode = 'development') {}
async checkSupabaseConnectivity() {
const url = await getVariable('NEXT_PUBLIC_SUPABASE_URL', this.mode);
if (!url) {
return {
status: 'error' as const,
message: 'No Supabase URL found in environment variables',
};
}
const anonKey = await getVariable(
'NEXT_PUBLIC_SUPABASE_ANON_KEY',
this.mode,
);
if (!anonKey) {
return {
status: 'error' as const,
message: 'No Supabase Anon Key found in environment variables',
};
}
const response = await fetch(`${url}/auth/v1/health`, {
headers: {
apikey: anonKey,
Authorization: `Bearer ${anonKey}`,
},
});
if (!response.ok) {
return {
status: 'error' as const,
message:
'Failed to connect to Supabase. The Supabase Anon Key or URL is not valid.',
};
}
return {
status: 'success' as const,
message: 'Connected to Supabase',
};
}
async checkSupabaseAdminConnectivity() {
const url = await getVariable('NEXT_PUBLIC_SUPABASE_URL', this.mode);
if (!url) {
return {
status: 'error' as const,
message: 'No Supabase URL found in environment variables',
};
}
const endpoint = `${url}/rest/v1/accounts`;
const apikey = await getVariable(
'NEXT_PUBLIC_SUPABASE_ANON_KEY',
this.mode,
);
if (!apikey) {
return {
status: 'error' as const,
message: 'No Supabase Anon Key found in environment variables',
};
}
const adminKey = await getVariable('SUPABASE_SERVICE_ROLE_KEY', this.mode);
if (!adminKey) {
return {
status: 'error' as const,
message: 'No Supabase Service Role Key found in environment variables',
};
}
const response = await fetch(endpoint, {
headers: {
apikey,
Authorization: `Bearer ${adminKey}`,
},
});
if (!response.ok) {
return {
status: 'error' as const,
message:
'Failed to connect to Supabase Admin. The Supabase Service Role Key is not valid.',
};
}
const data = await response.json();
if (data.length === 0) {
return {
status: 'error' as const,
message: 'No accounts found in Supabase Admin',
};
}
return {
status: 'success' as const,
message: 'Connected to Supabase Admin',
};
}
async checkStripeWebhookEndpoints() {
const secretKey = await getVariable('STRIPE_SECRET_KEY', this.mode);
if (!secretKey) {
return {
status: 'error' as const,
message: 'No Stripe Secret Key found in environment variables',
};
}
const webhooksSecret = await getVariable(
'STRIPE_WEBHOOKS_SECRET',
this.mode,
);
if (!webhooksSecret) {
return {
status: 'error' as const,
message: 'No Webhooks secret found in environment variables',
};
}
const url = `https://api.stripe.com`;
const request = await fetch(`${url}/v1/webhook_endpoints`, {
headers: {
Authorization: `Bearer ${secretKey}`,
},
});
if (!request.ok) {
return {
status: 'error' as const,
message:
'Failed to connect to Stripe. The Stripe Webhook Secret is not valid.',
};
}
const webhooks = await request.json();
if (webhooks.length === 0) {
return {
status: 'error' as const,
message: 'No webhooks found in Stripe',
};
}
const allWebhooksShareTheSameSecret = webhooks.every(
(webhook: any) => webhook.secret === webhooksSecret,
);
if (!allWebhooksShareTheSameSecret) {
return {
status: 'error' as const,
message: 'All webhooks do not share the same secret',
};
}
return {
status: 'success' as const,
message: 'All webhooks share the same Webhooks secret',
};
}
async checkStripeConnected() {
const secretKey = await getVariable('STRIPE_SECRET_KEY', this.mode);
if (!secretKey) {
return {
status: 'error' as const,
message: 'No Stripe Secret Key found in environment variables',
};
}
const url = `https://api.stripe.com`;
const request = await fetch(`${url}/v1/prices`, {
headers: {
Authorization: `Bearer ${secretKey}`,
},
});
if (!request.ok) {
return {
status: 'error' as const,
message:
'Failed to connect to Stripe. The Stripe Secret Key is not valid.',
};
}
return {
status: 'success' as const,
message: 'Connected to Stripe',
};
}
}

View File

@@ -0,0 +1,48 @@
import { ServiceCard } from '@/components/status-tile';
import { Page, PageBody, PageHeader } from '@kit/ui/page';
import { createConnectivityService } from './lib/connectivity-service';
import {EnvMode} from "@/app/variables/lib/types";
import {EnvModeSelector} from "@/components/env-mode-selector";
type DashboardPageProps = React.PropsWithChildren<{
searchParams: Promise<{ mode?: EnvMode }>;
}>;
export default async function DashboardPage(props: DashboardPageProps) {
const mode = (await props.searchParams).mode ?? 'development';
const connectivityService = createConnectivityService(mode);
const [
supabaseStatus,
supabaseAdminStatus,
stripeStatus,
stripeWebhookStatus,
] = await Promise.all([
connectivityService.checkSupabaseConnectivity(),
connectivityService.checkSupabaseAdminConnectivity(),
connectivityService.checkStripeConnected(),
connectivityService.checkStripeWebhookEndpoints(),
]);
return (
<Page style={'custom'}>
<PageHeader
displaySidebarTrigger={false}
description={'Check the status of your Supabase and Stripe services'}
>
<EnvModeSelector mode={mode} />
</PageHeader>
<PageBody className={'py-2'}>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<ServiceCard name={'Supabase API'} status={supabaseStatus} />
<ServiceCard name={'Supabase Admin'} status={supabaseAdminStatus} />
<ServiceCard name={'Stripe API'} status={stripeStatus} />
<ServiceCard name={'Stripe Webhook'} status={stripeWebhookStatus} />
</div>
</PageBody>
</Page>
);
}

View File

@@ -0,0 +1,241 @@
'use client';
import { useState } from 'react';
import { ChevronDownIcon } from 'lucide-react';
import { Button } from '@kit/ui/button';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { Input } from '@kit/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@kit/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@kit/ui/table';
import { cn } from '@kit/ui/utils';
import { defaultI18nNamespaces } from '../../../../web/lib/i18n/i18n.settings';
import type { TranslationData, Translations } from '../lib/translations-loader';
function flattenTranslations(
obj: TranslationData,
prefix = '',
result: Record<string, string> = {},
) {
for (const [key, value] of Object.entries(obj)) {
const newKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'string') {
result[newKey] = value;
} else {
flattenTranslations(value, newKey, result);
}
}
return result;
}
type FlattenedTranslations = Record<string, Record<string, string>>;
export function TranslationsComparison({
translations,
}: {
translations: Translations;
}) {
const [search, setSearch] = useState('');
const [selectedLocales, setSelectedLocales] = useState<Set<string>>();
const [selectedNamespace, setSelectedNamespace] = useState(
defaultI18nNamespaces[0] as string,
);
const locales = Object.keys(translations);
if (locales.length === 0) {
return <div>No translations found</div>;
}
const baseLocale = locales[0]!;
// Initialize selected locales if not set
if (!selectedLocales) {
setSelectedLocales(new Set(locales));
return null;
}
// Flatten translations for the selected namespace
const flattenedTranslations: FlattenedTranslations = {};
for (const locale of locales) {
const namespaceData = translations[locale]?.[selectedNamespace];
if (namespaceData) {
flattenedTranslations[locale] = flattenTranslations(namespaceData);
} else {
flattenedTranslations[locale] = {};
}
}
// Get all unique keys across all translations
const allKeys = Array.from(
new Set(
Object.values(flattenedTranslations).flatMap((data) => Object.keys(data)),
),
).sort();
const filteredKeys = allKeys.filter((key) =>
key.toLowerCase().includes(search.toLowerCase()),
);
const visibleLocales = locales.filter((locale) =>
selectedLocales.has(locale),
);
const copyTranslation = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch (error) {
console.error('Failed to copy text:', error);
}
};
const toggleLocale = (locale: string) => {
const newSelectedLocales = new Set(selectedLocales);
if (newSelectedLocales.has(locale)) {
if (newSelectedLocales.size > 1) {
newSelectedLocales.delete(locale);
}
} else {
newSelectedLocales.add(locale);
}
setSelectedLocales(newSelectedLocales);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<Input
type="search"
placeholder="Search translations..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-sm"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto">
Select Languages
<ChevronDownIcon className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
{locales.map((locale) => (
<DropdownMenuCheckboxItem
key={locale}
checked={selectedLocales.has(locale)}
onCheckedChange={() => toggleLocale(locale)}
disabled={
selectedLocales.size === 1 && selectedLocales.has(locale)
}
>
{locale}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Select
value={selectedNamespace}
onValueChange={setSelectedNamespace}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select namespace" />
</SelectTrigger>
<SelectContent>
{defaultI18nNamespaces.map((namespace: string) => (
<SelectItem key={namespace} value={namespace}>
{namespace}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Key</TableHead>
{visibleLocales.map((locale) => (
<TableHead key={locale}>{locale}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{filteredKeys.map((key) => (
<TableRow key={key}>
<TableCell className="font-mono text-sm">
<div className="flex items-center justify-between">
<span>{key}</span>
</div>
</TableCell>
{visibleLocales.map((locale) => {
const translations = flattenedTranslations[locale] ?? {};
const baseTranslations =
flattenedTranslations[baseLocale] ?? {};
const value = translations[key];
const baseValue = baseTranslations[key];
const isMissing = !value;
const isDifferent = value !== baseValue;
return (
<TableCell
key={locale}
className={cn({
'bg-destructive/10': isMissing,
'bg-warning/10': !isMissing && isDifferent,
})}
>
<div className="flex items-center justify-between">
<span>
{value || (
<span className="text-destructive">Missing</span>
)}
</span>
</div>
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const defaultI18nNamespaces = [
'common',
'auth',
'account',
'teams',
'billing',
'marketing',
];
export type TranslationData = {
[key: string]: string | TranslationData;
};
export type Translations = {
[locale: string]: {
[namespace: string]: TranslationData;
};
};
export async function loadTranslations() {
const localesPath = join(process.cwd(), '../web/public/locales');
const locales = readdirSync(localesPath);
const translations: Translations = {};
for (const locale of locales) {
translations[locale] = {};
for (const namespace of defaultI18nNamespaces) {
try {
const filePath = join(localesPath, locale, `${namespace}.json`);
const content = readFileSync(filePath, 'utf8');
translations[locale][namespace] = JSON.parse(content);
} catch (error) {
console.warn(
`Warning: Translation file not found for locale "${locale}" and namespace "${namespace}"`,
);
translations[locale][namespace] = {};
}
}
}
return translations;
}

View File

@@ -0,0 +1,35 @@
import { Metadata } from 'next';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Page, PageBody, PageHeader } from '@kit/ui/page';
import { TranslationsComparison } from './components/translations-comparison';
import { loadTranslations } from './lib/translations-loader';
export const metadata: Metadata = {
title: 'Translations Comparison',
description: 'Compare translations across different languages',
};
export default async function TranslationsPage() {
const translations = await loadTranslations();
return (
<Page style={'custom'}>
<PageHeader
displaySidebarTrigger={false}
description={
<AppBreadcrumbs
values={{
translations: 'Translations',
}}
/>
}
/>
<PageBody className={'py-4'}>
<TranslationsComparison translations={translations} />
</PageBody>
</Page>
);
}

View File

@@ -0,0 +1,721 @@
'use client';
import { Fragment, 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,
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';
export function AppEnvironmentVariablesManager({
state,
}: React.PropsWithChildren<{
state: AppEnvState;
}>) {
return (
<div className="flex flex-1 flex-col gap-y-4">
<Heading level={5}>Application: {state.appName}</Heading>
<div className={'flex flex-col space-y-4'}>
<EnvList appState={state} />
</div>
</div>
);
}
function EnvList({ appState }: { appState: AppEnvState }) {
const [expandedVars, setExpandedVars] = useState<Record<string, boolean>>({});
const [showValues, setShowValues] = useState<Record<string, boolean>>({});
const [search, setSearch] = useState('');
const searchParams = useSearchParams();
const secretVars = searchParams.get('secret') === 'true';
const publicVars = searchParams.get('public') === 'true';
const privateVars = searchParams.get('private') === 'true';
const overriddenVars = searchParams.get('overridden') === 'true';
const invalidVars = searchParams.get('invalid') === 'true';
const toggleExpanded = (key: string) => {
setExpandedVars((prev) => ({
...prev,
[key]: !prev[key],
}));
};
const toggleShowValue = (key: string) => {
setShowValues((prev) => ({
...prev,
[key]: !prev[key],
}));
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const renderValue = (value: string, isVisible: boolean) => {
if (!isVisible) {
return '••••••••';
}
return value || '(empty)';
};
const 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,
}),
{},
);
const validation = model?.validate
? model.validate({
value: varState.effectiveValue,
variables: allVariables,
mode: appState.mode,
})
: {
success: true,
error: undefined,
};
const canExpand = varState.definitions.length > 1 || !validation.success;
return (
<div key={varState.key} className="animate-in fade-in rounded-lg border">
<div className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1 flex-col gap-y-1">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold">
{varState.key}
</span>
{varState.isOverridden && (
<Badge variant="warning">Overridden</Badge>
)}
</div>
<If condition={model}>
{(model) => (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs font-normal">
{model.description}
</span>
</div>
)}
</If>
<div className="mt-2 flex items-center gap-2">
<div className="bg-muted text-muted-foreground flex-1 rounded px-2 py-2 font-mono text-xs">
{renderValue(varState.effectiveValue, isValueVisible)}
</div>
<If condition={!isClientBundledValue}>
<Button
variant="ghost"
size={'icon'}
onClick={() => toggleShowValue(varState.key)}
>
{isValueVisible ? <EyeOff size={16} /> : <Eye size={16} />}
</Button>
</If>
<Button
variant="ghost"
onClick={() => copyToClipboard(varState.effectiveValue)}
size={'icon'}
>
<Copy size={16} />
</Button>
</div>
</div>
{canExpand && (
<Button
size={'icon'}
variant="ghost"
className="ml-4 rounded p-1 hover:bg-gray-100"
onClick={() => toggleExpanded(varState.key)}
>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
)}
</div>
<div className="mt-2 flex gap-x-2">
<Badge
variant="outline"
className={cn({
'text-orange-500': !isClientBundledValue,
'text-green-500': isClientBundledValue,
})}
>
{isClientBundledValue ? `Public variable` : `Private variable`}
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="ml-2 h-3 w-3" />
</TooltipTrigger>
<TooltipContent>
{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`}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Badge>
<If condition={model?.secret}>
<Badge variant="outline" className={'text-destructive'}>
Secret Variable
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="ml-2 h-3 w-3" />
</TooltipTrigger>
<TooltipContent>
This is a secret key. Keep it safe!
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Badge>
</If>
<Badge
variant={'outline'}
className={cn({
'text-destructive':
varState.effectiveSource === '.env.production',
})}
>
{varState.effectiveSource}
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="ml-2 h-3 w-3" />
</TooltipTrigger>
<TooltipContent>
{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`}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Badge>
<If condition={varState.isOverridden}>
<Badge variant="warning">
Overridden in {varState.effectiveSource}
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="ml-2 h-3 w-3" />
</TooltipTrigger>
<TooltipContent>
This variable was overridden by a variable in{' '}
{varState.effectiveSource}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Badge>
</If>
<If condition={!validation.success}>
<Badge variant="destructive">
Invalid Value
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="ml-2 h-3 w-3" />
</TooltipTrigger>
<TooltipContent>
This variable has an invalid value. Drop down to view the
errors.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Badge>
</If>
</div>
</div>
{isExpanded && canExpand && (
<div className="flex flex-col gap-y-2 border-t bg-gray-50 p-4">
<If condition={!validation.success}>
<div className={'flex flex-col space-y-2'}>
<Heading level={6} className="Errors">
Errors
</Heading>
<Alert variant="destructive">
<AlertTitle>Invalid Value</AlertTitle>
<AlertDescription>
The value for {varState.key} is invalid:
<pre>
<code>{JSON.stringify(validation, null, 2)}</code>
</pre>
</AlertDescription>
</Alert>
</div>
</If>
<If condition={varState.definitions.length > 1}>
<div className={'flex flex-col space-y-2'}>
<Heading level={6} className="text-sm font-medium">
Override Chain
</Heading>
<div className="space-y-2">
{varState.definitions.map((def) => (
<div
key={`${def.key}-${def.source}`}
className="flex items-center gap-2"
>
<Badge
variant={'outline'}
className={cn({
'text-destructive': def.source === '.env.production',
})}
>
{def.source}
</Badge>
<div className="font-mono text-sm">
{renderValue(def.value, isValueVisible)}
</div>
</div>
))}
</div>
</div>
</If>
</div>
)}
</div>
);
};
const filterVariable = (varState: EnvVariableState) => {
const model = envVariables.find(
(variable) => variable.name === varState.key,
);
if (
!search &&
!secretVars &&
!publicVars &&
!privateVars &&
!invalidVars &&
!overriddenVars
) {
return true;
}
const isSecret = model?.secret;
const isPublic = varState.key.startsWith('NEXT_PUBLIC_');
const isPrivate = !isPublic;
const isInSearch = search
? varState.key.toLowerCase().includes(search.toLowerCase())
: true;
if (isPublic && publicVars && isInSearch) {
return true;
}
if (isSecret && secretVars && isInSearch) {
return true;
}
if (isPrivate && privateVars && isInSearch) {
return true;
}
if (overriddenVars && varState.isOverridden && isInSearch) {
return true;
}
if (invalidVars) {
const allVariables = Object.values(appState.variables).reduce(
(acc, variable) => ({
...acc,
[variable.key]: variable.effectiveValue,
}),
{},
);
const hasError =
model && model.validate
? !model.validate({
value: varState.effectiveValue,
variables: allVariables,
mode: appState.mode,
}).success
: false;
if (hasError && isInSearch) return true;
}
return false;
};
const groups = Object.values(appState.variables)
.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<EnvVariableState> }>,
);
return (
<div className="flex flex-col gap-y-8">
<div className="flex items-center">
<div className="flex w-full space-x-2">
<div>
<EnvModeSelector mode={appState.mode} />
</div>
<div>
<FilterSwitcher
filters={{
secret: secretVars,
public: publicVars,
overridden: overriddenVars,
private: privateVars,
invalid: invalidVars,
}}
/>
</div>
<Input
className={'w-full'}
placeholder="Search variables"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
onClick={() => {
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
</Button>
</TooltipTrigger>
<TooltipContent>
Create a report from the environment variables. Useful for
creating support tickets.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div className="flex flex-col gap-1">
<Summary appState={appState} />
{groups.map((group) => (
<div
key={group.category}
className="flex flex-col gap-y-2.5 border-b border-dashed py-8 last:border-b-0"
>
<div>
<span className={'text-sm font-bold uppercase'}>
{group.category}
</span>
</div>
<div className="flex flex-col space-y-4">
{group.variables.map((item) => {
return (
<Fragment key={item.key}>{renderVariable(item)}</Fragment>
);
})}
</div>
</div>
))}
<If condition={groups.length === 0}>
<div className="flex h-full flex-1 flex-col items-center justify-center gap-y-4 py-16">
<div className="text-muted-foreground text-sm">
No variables found
</div>
</div>
</If>
</div>
</div>
);
}
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 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 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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="font-normal">
{buttonLabel()}
<ChevronsUpDownIcon className="text-muted-foreground ml-1 h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuCheckboxItem
checked={allSelected}
onCheckedChange={() => {
handleFilterChange('all', true);
}}
>
All
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={secretVars}
onCheckedChange={() => {
handleFilterChange('secret', !secretVars);
}}
>
Secret
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={privateVars}
onCheckedChange={() => {
handleFilterChange('private', !privateVars);
}}
>
Private
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={publicVars}
onCheckedChange={() => {
handleFilterChange('public', !publicVars);
}}
>
Public
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={invalidVars}
onCheckedChange={() => {
handleFilterChange('invalid', !invalidVars);
}}
>
Invalid
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={overriddenVars}
onCheckedChange={() => {
handleFilterChange('overridden', !overriddenVars);
}}
>
Overridden
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function Summary({ appState }: { appState: AppEnvState }) {
const varsArray = Object.values(appState.variables);
const overridden = varsArray.filter((variable) => variable.isOverridden);
const allVariables = 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 (
<div className="flex flex-col space-y-4">
<div className="flex items-center gap-x-2">
<Badge variant={errors.length === 0 ? 'success' : 'destructive'}>
{errors.length} Errors
</Badge>
<Badge variant={overridden.length === 0 ? 'success' : 'warning'}>
{overridden.length} Overridden Variables
</Badge>
</div>
</div>
);
}

View File

@@ -0,0 +1,244 @@
import 'server-only';
import { envVariables } from '@/app/variables/lib/env-variables-model';
import fs from 'fs/promises';
import path from 'path';
import {
AppEnvState,
EnvFileInfo,
EnvMode,
EnvVariableState,
ScanOptions,
} from './types';
// Define precedence order for each mode
const ENV_FILE_PRECEDENCE: Record<EnvMode, string[]> = {
development: [
'.env',
'.env.development',
'.env.local',
'.env.development.local',
],
production: [
'.env',
'.env.production',
'.env.local',
'.env.production.local',
],
};
function getSourcePrecedence(source: string, mode: EnvMode): number {
return ENV_FILE_PRECEDENCE[mode].indexOf(source);
}
export async function scanMonorepoEnv(
options: ScanOptions,
): Promise<EnvFileInfo[]> {
const {
rootDir = path.resolve(process.cwd(), '../..'),
apps = ['web'],
mode,
} = options;
const envTypes = ENV_FILE_PRECEDENCE[mode];
const appsDir = path.join(rootDir, 'apps');
const results: EnvFileInfo[] = [];
try {
const appDirs = await fs.readdir(appsDir);
for (const appName of appDirs) {
if (apps.length > 0 && !apps.includes(appName)) {
continue;
}
const appDir = path.join(appsDir, appName);
const stat = await fs.stat(appDir);
if (!stat.isDirectory()) {
continue;
}
const appInfo: EnvFileInfo = {
appName,
filePath: appDir,
variables: [],
};
for (const envType of envTypes) {
const envPath = path.join(appDir, envType);
try {
const content = await fs.readFile(envPath, 'utf-8');
const vars = parseEnvFile(content, envType);
appInfo.variables.push(...vars);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn(`Error reading ${envPath}:`, error);
}
}
}
if (appInfo.variables.length > 0) {
results.push(appInfo);
}
}
} catch (error) {
console.error('Error scanning monorepo:', error);
throw error;
}
return results;
}
function parseEnvFile(content: string, source: string) {
const variables: Array<{ key: string; value: string; source: string }> = [];
const lines = content.split('\n');
for (const line of lines) {
// Skip comments and empty lines
if (line.trim().startsWith('#') || !line.trim()) {
continue;
}
// Match KEY=VALUE pattern, handling quotes
const match = line.match(/^([^=]+)=(.*)$/);
if (match) {
const [, key = '', rawValue] = match;
let value = rawValue ?? '';
// Remove quotes if present
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
// Handle escaped quotes within the value
value = value
.replace(/\\"/g, '"')
.replace(/\\'/g, "'")
.replace(/\\\\/g, '\\');
variables.push({
key: key.trim(),
value: value.trim(),
source,
});
}
}
return variables;
}
export function processEnvDefinitions(
envInfo: EnvFileInfo,
mode: EnvMode,
): AppEnvState {
const variableMap: Record<string, EnvVariableState> = {};
// First pass: Collect all definitions
for (const variable of envInfo.variables) {
if (!variable) {
continue;
}
const model = envVariables.find((v) => variable.key === v.name);
if (!variableMap[variable.key]) {
variableMap[variable.key] = {
key: variable.key,
definitions: [],
effectiveValue: variable.value,
effectiveSource: variable.source,
isOverridden: false,
category: model ? model.category : 'Custom',
};
}
const varState = variableMap[variable.key];
if (!varState) {
continue;
}
varState.definitions.push({
key: variable.key,
value: variable.value,
source: variable.source,
});
}
// Second pass: Determine effective values and override status
for (const key in variableMap) {
const varState = variableMap[key];
if (!varState) {
continue;
}
// Sort definitions by mode-specific precedence
varState.definitions.sort(
(a, b) =>
getSourcePrecedence(a.source, mode) -
getSourcePrecedence(b.source, mode),
);
if (varState.definitions.length > 1) {
const lastDef = varState.definitions[varState.definitions.length - 1];
if (!lastDef) {
continue;
}
const highestPrecedence = getSourcePrecedence(lastDef.source, mode);
varState.isOverridden = true;
varState.effectiveValue = lastDef.value;
varState.effectiveSource = lastDef.source;
// Check for conflicts at highest precedence
const conflictingDefs = varState.definitions.filter(
(def) => getSourcePrecedence(def.source, mode) === highestPrecedence,
);
if (conflictingDefs.length > 1) {
varState.effectiveSource = `${varState.effectiveSource}`;
}
}
}
return {
appName: envInfo.appName,
filePath: envInfo.filePath,
mode,
variables: variableMap,
};
}
export async function getEnvState(
options: ScanOptions,
): Promise<AppEnvState[]> {
const envInfos = await scanMonorepoEnv(options);
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'] });
// Find the state for the "web" app.
const webState = envStates.find((state) => state.appName === 'web');
// Return the effectiveValue based on override status.
return webState?.variables[key]?.effectiveValue ?? '';
}

View File

@@ -0,0 +1,770 @@
import { EnvMode } from '@/app/variables/lib/types';
import { z } from 'zod';
export type EnvVariableModel = {
name: string;
description: string;
secret?: boolean;
category: string;
test?: (value: string) => Promise<boolean>;
validate?: (props: {
value: string;
variables: Record<string, string>;
mode: EnvMode;
}) => z.SafeParseReturnType<unknown, unknown>;
};
export const envVariables: EnvVariableModel[] = [
{
name: 'NEXT_PUBLIC_SITE_URL',
description:
'The URL of your site, used for generating absolute URLs. Must include the protocol.',
category: 'Site Configuration',
validate: ({ value, mode }) => {
if (mode === 'development') {
return z
.string()
.url({
message: `The NEXT_PUBLIC_SITE_URL variable must be a valid URL`,
})
.safeParse(value);
}
return z
.string()
.url({
message: `The NEXT_PUBLIC_SITE_URL variable must be a valid URL`,
})
.startsWith(
'https',
`The NEXT_PUBLIC_SITE_URL variable must start with https`,
)
.safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_PRODUCT_NAME',
description:
"Your product's name, used consistently across the application interface.",
category: 'Site Configuration',
validate: ({ value }) => {
return z
.string()
.min(
1,
`The NEXT_PUBLIC_PRODUCT_NAME variable must be at least 1 character`,
)
.safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_SITE_TITLE',
description:
"The site's title tag content, crucial for SEO and browser display.",
category: 'Site Configuration',
validate: ({ value }) => {
return z
.string()
.min(
1,
`The NEXT_PUBLIC_SITE_TITLE variable must be at least 1 character`,
)
.safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_SITE_DESCRIPTION',
description:
"Your site's meta description, important for SEO optimization.",
category: 'Site Configuration',
validate: ({ value }) => {
return z
.string()
.min(
1,
`The NEXT_PUBLIC_SITE_DESCRIPTION variable must be at least 1 character`,
)
.optional()
.safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_DEFAULT_LOCALE',
description: 'Sets the default language for your application.',
category: 'Localization',
validate: ({ value }) => {
return z
.string()
.min(
1,
`The NEXT_PUBLIC_DEFAULT_LOCALE variable must be at least 1 character`,
)
.optional()
.safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_AUTH_PASSWORD',
description: 'Enables or disables password-based authentication.',
category: 'Authentication',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_AUTH_MAGIC_LINK',
description: 'Enables or disables magic link authentication.',
category: 'Authentication',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_CAPTCHA_SITE_KEY',
description: 'Your Cloudflare Captcha site key for form protection.',
category: 'Security',
validate: ({ value }) => {
return z.string().optional().safeParse(value);
},
},
{
name: 'CAPTCHA_SECRET_TOKEN',
description:
'Your Cloudflare Captcha secret token for backend verification.',
category: 'Security',
secret: true,
validate: ({ value }) => {
return z
.string()
.min(
1,
`The CAPTCHA_SECRET_TOKEN variable must be at least 1 character`,
)
.optional()
.safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_USER_NAVIGATION_STYLE',
description:
'Controls user navigation layout. Options: sidebar, header, or custom.',
category: 'Navigation',
validate: ({ value }) => {
return z
.enum(['sidebar', 'header', 'custom'])
.optional()
.safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_HOME_SIDEBAR_COLLAPSED',
description: 'Sets the default state of the home sidebar.',
category: 'Navigation',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_TEAM_NAVIGATION_STYLE',
description:
'Controls team navigation layout. Options: sidebar, header, or custom.',
category: 'Navigation',
validate: ({ value }) => {
return z
.enum(['sidebar', 'header', 'custom'])
.optional()
.safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_TEAM_SIDEBAR_COLLAPSED',
description: 'Sets the default state of the team sidebar.',
category: 'Navigation',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE',
description:
'Defines sidebar collapse behavior. Options: offscreen, icon, or none.',
category: 'Navigation',
validate: ({ value }) => {
return z.enum(['offscreen', 'icon', 'none']).optional().safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_DEFAULT_THEME_MODE',
description:
'Controls the default theme appearance. Options: light, dark, or system.',
category: 'Theme',
validate: ({ value }) => {
return z.enum(['light', 'dark', 'system']).optional().safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_ENABLE_THEME_TOGGLE',
description: 'Controls visibility of the theme toggle feature.',
category: 'Theme',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_ENABLE_SIDEBAR_TRIGGER',
description: 'Controls visibility of the sidebar trigger feature.',
category: 'Navigation',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION',
description: 'Allows users to delete their personal accounts.',
category: 'Features',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING',
description: 'Enables billing features for personal accounts.',
category: 'Features',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS',
description: 'Master switch for team account functionality.',
category: 'Features',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION',
description: 'Controls ability to create new team accounts.',
category: 'Features',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION',
description: 'Allows team account deletion.',
category: 'Features',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING',
description: 'Enables billing features for team accounts.',
category: 'Features',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_ENABLE_NOTIFICATIONS',
description: 'Controls the notification system.',
category: 'Notifications',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_REALTIME_NOTIFICATIONS',
description: 'Enables real-time notifications using Supabase Realtime.',
category: 'Notifications',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_SUPABASE_URL',
description: 'Your Supabase project URL.',
category: 'Supabase',
validate: ({ value, mode }) => {
if (mode === 'development') {
return z
.string()
.url({
message: `The NEXT_PUBLIC_SUPABASE_URL variable must be a valid URL`,
})
.safeParse(value);
}
return z
.string()
.url({
message: `The NEXT_PUBLIC_SUPABASE_URL variable must be a valid URL`,
})
.startsWith(
'https',
`The NEXT_PUBLIC_SUPABASE_URL variable must start with https`,
)
.safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_SUPABASE_ANON_KEY',
description: 'Your Supabase anonymous API key.',
category: 'Supabase',
validate: ({ value }) => {
return z
.string()
.min(
1,
`The NEXT_PUBLIC_SUPABASE_ANON_KEY variable must be at least 1 character`,
)
.optional()
.safeParse(value);
},
},
{
name: 'SUPABASE_SERVICE_ROLE_KEY',
description: 'Your Supabase service role key (keep this secret!).',
category: 'Supabase',
secret: true,
validate: ({ value, variables }) => {
return z
.string()
.min(
1,
`The SUPABASE_SERVICE_ROLE_KEY variable must be at least 1 character`,
)
.optional()
.refine(
(value) => {
return value !== variables['NEXT_PUBLIC_SUPABASE_ANON_KEY'];
},
{
message: `The SUPABASE_SERVICE_ROLE_KEY variable must be different from NEXT_PUBLIC_SUPABASE_ANON_KEY`,
},
)
.safeParse(value);
},
},
{
name: 'SUPABASE_DB_WEBHOOK_SECRET',
description: 'Secret key for Supabase webhook verification.',
category: 'Supabase',
secret: true,
validate: ({ value }) => {
return z
.string()
.min(
1,
`The SUPABASE_DB_WEBHOOK_SECRET variable must be at least 1 character`,
)
.optional()
.safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_BILLING_PROVIDER',
description:
'Your chosen billing provider. Options: stripe or lemon-squeezy.',
category: 'Billing',
validate: ({ value }) => {
return z.enum(['stripe', 'lemon-squeezy']).optional().safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY',
description: 'Your Stripe publishable key.',
category: 'Billing',
validate: ({ value }) => {
return z
.string()
.min(
1,
`The NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY variable must be at least 1 character`,
)
.optional()
.safeParse(value);
},
},
{
name: 'STRIPE_SECRET_KEY',
description: 'Your Stripe secret key.',
category: 'Billing',
secret: true,
validate: ({ value }) => {
return z
.string()
.min(1, `The STRIPE_SECRET_KEY variable must be at least 1 character`)
.optional()
.safeParse(value);
},
},
{
name: 'STRIPE_WEBHOOK_SECRET',
description: 'Your Stripe webhook secret.',
category: 'Billing',
secret: true,
validate: ({ value }) => {
return z
.string()
.min(
1,
`The STRIPE_WEBHOOK_SECRET variable must be at least 1 character`,
)
.optional()
.safeParse(value);
},
},
{
name: 'LEMON_SQUEEZY_SECRET_KEY',
description: 'Your Lemon Squeezy secret key.',
category: 'Billing',
secret: true,
validate: ({ value }) => {
return z
.string()
.min(
1,
`The LEMON_SQUEEZY_SECRET_KEY variable must be at least 1 character`,
)
.optional()
.safeParse(value);
},
},
{
name: 'LEMON_SQUEEZY_STORE_ID',
description: 'Your Lemon Squeezy store ID.',
category: 'Billing',
validate: ({ value }) => {
return z
.string()
.min(
1,
`The LEMON_SQUEEZY_STORE_ID variable must be at least 1 character`,
)
.optional()
.safeParse(value);
},
},
{
name: 'LEMON_SQUEEZY_SIGNING_SECRET',
description: 'Your Lemon Squeezy signing secret.',
category: 'Billing',
secret: true,
validate: ({ value }) => {
return z
.string()
.min(
1,
`The LEMON_SQUEEZY_SIGNING_SECRET variable must be at least 1 character`,
)
.optional()
.safeParse(value);
},
},
{
name: 'MAILER_PROVIDER',
description: 'Your email service provider. Options: nodemailer or resend.',
category: 'Email',
validate: ({ value }) => {
return z.enum(['nodemailer', 'resend']).optional().safeParse(value);
},
},
{
name: 'EMAIL_SENDER',
description: 'Default sender email address.',
category: 'Email',
validate: ({ value }) => {
return z
.string()
.min(1, `The EMAIL_SENDER variable must be at least 1 character`)
.safeParse(value);
},
},
{
name: 'CONTACT_EMAIL',
description: 'Email address for contact form submissions.',
category: 'Email',
validate: ({ value }) => {
return z
.string()
.email()
.min(1, `The CONTACT_EMAIL variable must be at least 1 character`)
.safeParse(value);
},
},
{
name: 'RESEND_API_KEY',
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);
},
},
{
name: 'EMAIL_HOST',
description: 'SMTP host for Nodemailer configuration.',
category: 'Email',
validate: ({ value }) => {
return z.string().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);
},
},
{
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);
},
},
{
name: 'EMAIL_PASSWORD',
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);
},
},
{
name: 'EMAIL_TLS',
description: 'Whether to use TLS for SMTP connection.',
category: 'Email',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
},
{
name: 'CMS_CLIENT',
description: 'Your chosen CMS system. Options: wordpress or keystatic.',
category: 'CMS',
validate: ({ value }) => {
return z.enum(['wordpress', 'keystatic']).optional().safeParse(value);
},
},
{
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);
},
},
{
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);
},
},
{
name: 'KEYSTATIC_GITHUB_TOKEN',
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);
},
},
{
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);
},
},
{
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);
},
},
{
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);
},
},
{
name: 'NEXT_PUBLIC_LOCALES_PATH',
description: 'The path to your locales folder.',
category: 'Localization',
validate: ({ value }) => {
return z
.string()
.min(
1,
`The NEXT_PUBLIC_LOCALES_PATH variable must be at least 1 character`,
)
.optional()
.safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_LANGUAGE_PRIORITY',
description: 'The priority setting as to how infer the language.',
category: 'Localization',
validate: ({ value }) => {
return z.enum(['user', 'application']).optional().safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_ENABLE_VERSION_UPDATER',
description:
'Enables the version updater to poll the latest version and notify the user.',
category: 'Features',
validate: ({ value }) => {
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.',
category: 'Features',
validate: ({ value }) => {
return z.coerce
.number()
.min(
1,
`The NEXT_PUBLIC_VERSION_UPDATER_REFETCH_INTERVAL_SECONDS variable must be at least 1 character`,
)
.max(
86400,
`The NEXT_PUBLIC_VERSION_UPDATER_REFETCH_INTERVAL_SECONDS variable must be at most 86400`,
)
.optional()
.safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_THEME_COLOR',
description: 'The default theme color.',
category: 'Theme',
validate: ({ value }) => {
return z
.string()
.min(
1,
`The NEXT_PUBLIC_THEME_COLOR variable must be at least 1 character`,
)
.optional()
.safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_THEME_COLOR_DARK',
description: 'The default theme color for dark mode.',
category: 'Theme',
validate: ({ value }) => {
return z
.string()
.min(
1,
`The NEXT_PUBLIC_THEME_COLOR_DARK variable must be at least 1 character`,
)
.optional()
.safeParse(value);
},
},
];

View File

@@ -0,0 +1,40 @@
export type EnvMode = 'development' | 'production';
export type ScanOptions = {
apps?: string[];
rootDir?: string;
mode: EnvMode;
};
export type EnvDefinition = {
key: string;
value: string;
source: string;
};
export type EnvVariableState = {
key: string;
category: string;
definitions: EnvDefinition[];
effectiveValue: string;
isOverridden: boolean;
effectiveSource: string;
};
export type AppEnvState = {
appName: string;
filePath: string;
mode: EnvMode;
variables: Record<string, EnvVariableState>;
};
export type EnvFileInfo = {
appName: string;
filePath: string;
variables: Array<{
key: string;
value: string;
source: string;
}>;
};

View File

@@ -0,0 +1,55 @@
import { use } from 'react';
import {
processEnvDefinitions,
scanMonorepoEnv,
} from '@/app/variables/lib/env-scanner';
import { EnvMode } from '@/app/variables/lib/types';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Page, PageBody, PageHeader } from '@kit/ui/page';
import { AppEnvironmentVariablesManager } from './components/app-environment-variables-manager';
type VariablesPageProps = {
searchParams: Promise<{ mode?: EnvMode }>;
};
export const metadata = {
title: 'Environment Variables',
};
export default function VariablesPage({ searchParams }: VariablesPageProps) {
const { mode = 'development' } = use(searchParams);
const apps = use(scanMonorepoEnv({ mode }));
return (
<Page style={'custom'}>
<PageHeader
displaySidebarTrigger={false}
description={
<AppBreadcrumbs
values={{
variables: 'Environment Variables',
}}
/>
}
/>
<PageBody>
<div className={'flex flex-col space-y-4 pb-16'}>
{apps.map((app) => {
const appEnvState = processEnvDefinitions(app, mode);
return (
<AppEnvironmentVariablesManager
key={app.appName}
state={appEnvState}
/>
);
})}
</div>
</PageBody>
</Page>
);
}