committed by
GitHub
parent
59dfc0ad91
commit
c185bcfa11
199
apps/dev-tool/app/emails/[id]/components/email-tester-form.tsx
Normal file
199
apps/dev-tool/app/emails/[id]/components/email-tester-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
apps/dev-tool/app/emails/[id]/page.tsx
Normal file
104
apps/dev-tool/app/emails/[id]/page.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
57
apps/dev-tool/app/emails/lib/email-loader.tsx
Normal file
57
apps/dev-tool/app/emails/lib/email-loader.tsx
Normal 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'),
|
||||
};
|
||||
}
|
||||
11
apps/dev-tool/app/emails/lib/email-tester-form-schema.ts
Normal file
11
apps/dev-tool/app/emails/lib/email-tester-form-schema.ts
Normal 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(),
|
||||
});
|
||||
38
apps/dev-tool/app/emails/lib/server-actions.ts
Normal file
38
apps/dev-tool/app/emails/lib/server-actions.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
83
apps/dev-tool/app/emails/page.tsx
Normal file
83
apps/dev-tool/app/emails/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
apps/dev-tool/app/layout.tsx
Normal file
27
apps/dev-tool/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
213
apps/dev-tool/app/lib/connectivity-service.ts
Normal file
213
apps/dev-tool/app/lib/connectivity-service.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
48
apps/dev-tool/app/page.tsx
Normal file
48
apps/dev-tool/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
46
apps/dev-tool/app/translations/lib/translations-loader.ts
Normal file
46
apps/dev-tool/app/translations/lib/translations-loader.ts
Normal 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;
|
||||
}
|
||||
35
apps/dev-tool/app/translations/page.tsx
Normal file
35
apps/dev-tool/app/translations/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
244
apps/dev-tool/app/variables/lib/env-scanner.ts
Normal file
244
apps/dev-tool/app/variables/lib/env-scanner.ts
Normal 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 ?? '';
|
||||
}
|
||||
770
apps/dev-tool/app/variables/lib/env-variables-model.ts
Normal file
770
apps/dev-tool/app/variables/lib/env-variables-model.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
];
|
||||
40
apps/dev-tool/app/variables/lib/types.ts
Normal file
40
apps/dev-tool/app/variables/lib/types.ts
Normal 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;
|
||||
}>;
|
||||
};
|
||||
55
apps/dev-tool/app/variables/page.tsx
Normal file
55
apps/dev-tool/app/variables/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user