2.18.0: New Invitation flow, refactored Database Webhooks, new ShadCN UI Components (#384)
* Streamlined invitations flow * Removed web hooks in favor of handling logic directly in server actions * Added new Shadcn UI Components
This commit is contained in:
committed by
GitHub
parent
195cf41680
commit
2e20d3e76f
@@ -2,6 +2,7 @@
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { CheckIcon } from '@radix-ui/react-icons';
|
||||
import { Mail } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -13,11 +14,14 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from '@kit/ui/input-group';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
@@ -89,50 +93,57 @@ export function UpdateEmailForm({
|
||||
</If>
|
||||
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<FormField
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'account:newEmail'} />
|
||||
</FormLabel>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<FormField
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<InputGroup className="dark:bg-background">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<Mail className="h-4 w-4" />
|
||||
</InputGroupAddon>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'account-email-form-email-input'}
|
||||
required
|
||||
type={'email'}
|
||||
placeholder={''}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<InputGroupInput
|
||||
data-test={'account-email-form-email-input'}
|
||||
required
|
||||
type={'email'}
|
||||
placeholder={t('account:newEmail')}
|
||||
{...field}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
name={'email'}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
name={'email'}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'account:repeatEmail'} />
|
||||
</FormLabel>
|
||||
<FormField
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<InputGroup className="dark:bg-background">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<Mail className="h-4 w-4" />
|
||||
</InputGroupAddon>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
data-test={'account-email-form-repeat-email-input'}
|
||||
required
|
||||
type={'email'}
|
||||
/>
|
||||
</FormControl>
|
||||
<InputGroupInput
|
||||
{...field}
|
||||
data-test={'account-email-form-repeat-email-input'}
|
||||
required
|
||||
type={'email'}
|
||||
placeholder={t('account:repeatEmail')}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
name={'repeatEmail'}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
name={'repeatEmail'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button disabled={updateUserMutation.isPending}>
|
||||
|
||||
@@ -20,6 +20,15 @@ import {
|
||||
} from '@kit/ui/alert-dialog';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@kit/ui/item';
|
||||
import { OauthProviderLogoImage } from '@kit/ui/oauth-provider-logo-image';
|
||||
import { Separator } from '@kit/ui/separator';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
@@ -90,73 +99,76 @@ export function LinkAccountsList(props: { providers: Provider[] }) {
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
{connectedIdentities.map((identity) => (
|
||||
<div
|
||||
key={identity.id}
|
||||
className="bg-muted/50 flex h-14 items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Item key={identity.id} variant="outline">
|
||||
<ItemMedia>
|
||||
<OauthProviderLogoImage providerId={identity.provider} />
|
||||
</ItemMedia>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<span className="flex items-center gap-x-2 text-sm font-medium capitalize">
|
||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
||||
<ItemContent>
|
||||
<ItemHeader className="flex items-center gap-3">
|
||||
<div className="flex flex-col">
|
||||
<ItemTitle className="flex items-center gap-x-2 text-sm font-medium capitalize">
|
||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
||||
|
||||
<span>{identity.provider}</span>
|
||||
</span>
|
||||
<span>{identity.provider}</span>
|
||||
</ItemTitle>
|
||||
|
||||
<If condition={identity.identity_data?.email}>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{identity.identity_data?.email as string}
|
||||
</span>
|
||||
</If>
|
||||
</div>
|
||||
</div>
|
||||
<If condition={identity.identity_data?.email}>
|
||||
<ItemDescription>
|
||||
{identity.identity_data?.email as string}
|
||||
</ItemDescription>
|
||||
</If>
|
||||
</div>
|
||||
</ItemHeader>
|
||||
</ItemContent>
|
||||
|
||||
<If condition={hasMultipleIdentities}>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={unlinkMutation.isPending}
|
||||
>
|
||||
<If condition={unlinkMutation.isPending}>
|
||||
<Spinner className="mr-2 h-3 w-3" />
|
||||
</If>
|
||||
<Trans i18nKey={'account:unlinkAccount'} />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<Trans i18nKey={'account:confirmUnlinkAccount'} />
|
||||
</AlertDialogTitle>
|
||||
|
||||
<AlertDialogDescription>
|
||||
<Trans
|
||||
i18nKey={'account:unlinkAccountConfirmation'}
|
||||
values={{ provider: identity.provider }}
|
||||
/>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
|
||||
<AlertDialogAction
|
||||
onClick={() => handleUnlinkAccount(identity)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
<ItemActions>
|
||||
<If condition={hasMultipleIdentities}>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={unlinkMutation.isPending}
|
||||
>
|
||||
<If condition={unlinkMutation.isPending}>
|
||||
<Spinner className="mr-2 h-3 w-3" />
|
||||
</If>
|
||||
<Trans i18nKey={'account:unlinkAccount'} />
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</If>
|
||||
</div>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<Trans i18nKey={'account:confirmUnlinkAccount'} />
|
||||
</AlertDialogTitle>
|
||||
|
||||
<AlertDialogDescription>
|
||||
<Trans
|
||||
i18nKey={'account:unlinkAccountConfirmation'}
|
||||
values={{ provider: identity.provider }}
|
||||
/>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
|
||||
<AlertDialogAction
|
||||
onClick={() => handleUnlinkAccount(identity)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
<Trans i18nKey={'account:unlinkAccount'} />
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</If>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,19 +191,28 @@ export function LinkAccountsList(props: { providers: Provider[] }) {
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
{availableProviders.map((provider) => (
|
||||
<button
|
||||
<Item
|
||||
key={provider}
|
||||
className="hover:bg-muted/50 flex h-14 items-center justify-between rounded-lg border p-3 transition-colors"
|
||||
variant="outline"
|
||||
onClick={() => handleLinkAccount(provider)}
|
||||
role="button"
|
||||
className="hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ItemMedia>
|
||||
<OauthProviderLogoImage providerId={provider} />
|
||||
</ItemMedia>
|
||||
|
||||
<span className="text-sm font-medium capitalize">
|
||||
{provider}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<ItemContent>
|
||||
<ItemTitle className="capitalize">{provider}</ItemTitle>
|
||||
|
||||
<ItemDescription>
|
||||
<Trans
|
||||
i18nKey={'account:linkAccountDescription'}
|
||||
values={{ provider }}
|
||||
/>
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,13 @@ import {
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@kit/ui/item';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Spinner } from '@kit/ui/spinner';
|
||||
import {
|
||||
@@ -100,17 +107,21 @@ function FactorsTableContainer(props: { userId: string }) {
|
||||
if (!allFactors.length) {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Alert>
|
||||
<ShieldCheck className={'h-4'} />
|
||||
<Item variant="outline">
|
||||
<ItemMedia>
|
||||
<ShieldCheck className={'h-4'} />
|
||||
</ItemMedia>
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'account:multiFactorAuthHeading'} />
|
||||
</AlertTitle>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<Trans i18nKey={'account:multiFactorAuthHeading'} />
|
||||
</ItemTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'account:multiFactorAuthDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<ItemDescription>
|
||||
<Trans i18nKey={'account:multiFactorAuthDescription'} />
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Check, Lock } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -17,12 +17,14 @@ import {
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from '@kit/ui/input-group';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
@@ -101,62 +103,68 @@ export const UpdatePasswordForm = ({
|
||||
<NeedsReauthenticationAlert />
|
||||
</If>
|
||||
|
||||
<FormField
|
||||
name={'newPassword'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Label>
|
||||
<Trans i18nKey={'account:newPassword'} />
|
||||
</Label>
|
||||
</FormLabel>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<FormField
|
||||
name={'newPassword'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<InputGroup className="dark:bg-background">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<Lock className="h-4 w-4" />
|
||||
</InputGroupAddon>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'account-password-form-password-input'}
|
||||
autoComplete={'new-password'}
|
||||
required
|
||||
type={'password'}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<InputGroupInput
|
||||
data-test={'account-password-form-password-input'}
|
||||
autoComplete={'new-password'}
|
||||
required
|
||||
type={'password'}
|
||||
placeholder={t('account:newPassword')}
|
||||
{...field}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name={'repeatPassword'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Label>
|
||||
<Trans i18nKey={'account:repeatPassword'} />
|
||||
</Label>
|
||||
</FormLabel>
|
||||
<FormField
|
||||
name={'repeatPassword'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<InputGroup className="dark:bg-background">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<Lock className="h-4 w-4" />
|
||||
</InputGroupAddon>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'account-password-form-repeat-password-input'}
|
||||
required
|
||||
type={'password'}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<InputGroupInput
|
||||
data-test={
|
||||
'account-password-form-repeat-password-input'
|
||||
}
|
||||
required
|
||||
type={'password'}
|
||||
placeholder={t('account:repeatPassword')}
|
||||
{...field}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans i18nKey={'account:repeatPasswordDescription'} />
|
||||
</FormDescription>
|
||||
<FormDescription>
|
||||
<Trans i18nKey={'account:repeatPasswordDescription'} />
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button disabled={updateUserMutation.isPending}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { User } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -9,10 +10,13 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from '@kit/ui/input-group';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
@@ -66,18 +70,20 @@ export function UpdateAccountDetailsForm({
|
||||
name={'displayName'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'account:name'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'account-display-name'}
|
||||
minLength={2}
|
||||
placeholder={''}
|
||||
maxLength={100}
|
||||
{...field}
|
||||
/>
|
||||
<InputGroup className="dark:bg-background">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<User className="h-4 w-4" />
|
||||
</InputGroupAddon>
|
||||
|
||||
<InputGroupInput
|
||||
data-test={'account-display-name'}
|
||||
minLength={2}
|
||||
placeholder={t('account:name')}
|
||||
maxLength={100}
|
||||
{...field}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@@ -85,8 +85,10 @@ export const deletePersonalAccountAction = enhanceAction(
|
||||
// delete the user's account and cancel all subscriptions
|
||||
await service.deletePersonalAccount({
|
||||
adminClient: getSupabaseServerAdminClient(),
|
||||
userId: user.id,
|
||||
userEmail: user.email ?? null,
|
||||
account: {
|
||||
id: user.id,
|
||||
email: user.email ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// sign out the user after deleting their account
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
@@ -30,13 +32,14 @@ class DeletePersonalAccountService {
|
||||
*/
|
||||
async deletePersonalAccount(params: {
|
||||
adminClient: SupabaseClient<Database>;
|
||||
|
||||
userId: string;
|
||||
userEmail: string | null;
|
||||
account: {
|
||||
id: string;
|
||||
email: string | null;
|
||||
};
|
||||
}) {
|
||||
const logger = await getLogger();
|
||||
|
||||
const userId = params.userId;
|
||||
const userId = params.account.id;
|
||||
const ctx = { userId, name: this.namespace };
|
||||
|
||||
logger.info(
|
||||
@@ -54,6 +57,14 @@ class DeletePersonalAccountService {
|
||||
|
||||
logger.info(ctx, 'User successfully deleted!');
|
||||
|
||||
if (params.account.email) {
|
||||
// dispatch the delete account email. Errors are handled in the method.
|
||||
await this.dispatchDeleteAccountEmail({
|
||||
email: params.account.email,
|
||||
id: params.account.id,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
@@ -69,4 +80,71 @@ class DeletePersonalAccountService {
|
||||
throw new Error('Error deleting user');
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatchDeleteAccountEmail(account: {
|
||||
email: string;
|
||||
id: string;
|
||||
}) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: this.namespace, userId: account.id };
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Sending delete account email...');
|
||||
|
||||
await this.sendDeleteAccountEmail(account);
|
||||
|
||||
logger.info(ctx, 'Delete account email sent successfully');
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
...ctx,
|
||||
error,
|
||||
},
|
||||
'Failed to send delete account email',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendDeleteAccountEmail(account: { email: string }) {
|
||||
const emailSettings = this.getEmailSettings();
|
||||
|
||||
const { renderAccountDeleteEmail } = await import('@kit/email-templates');
|
||||
const { getMailer } = await import('@kit/mailers');
|
||||
|
||||
const mailer = await getMailer();
|
||||
|
||||
const { html, subject } = await renderAccountDeleteEmail({
|
||||
productName: emailSettings.productName,
|
||||
});
|
||||
|
||||
await mailer.sendEmail({
|
||||
from: emailSettings.fromEmail,
|
||||
html,
|
||||
subject,
|
||||
to: account.email,
|
||||
});
|
||||
}
|
||||
|
||||
private getEmailSettings() {
|
||||
const productName = process.env.NEXT_PUBLIC_PRODUCT_NAME;
|
||||
const fromEmail = process.env.EMAIL_SENDER;
|
||||
|
||||
return z
|
||||
.object({
|
||||
productName: z
|
||||
.string({
|
||||
required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
|
||||
})
|
||||
.min(1),
|
||||
fromEmail: z
|
||||
.string({
|
||||
required_error: 'EMAIL_SENDER is required',
|
||||
})
|
||||
.min(1),
|
||||
})
|
||||
.parse({
|
||||
productName,
|
||||
fromEmail,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user