diff --git a/apps/remix/app/components/general/admin-email-blocklist-section.tsx b/apps/remix/app/components/general/admin-email-blocklist-section.tsx new file mode 100644 index 000000000..7d074621a --- /dev/null +++ b/apps/remix/app/components/general/admin-email-blocklist-section.tsx @@ -0,0 +1,171 @@ +import { + SITE_SETTINGS_EMAIL_BLOCKLIST_ID, + type TSiteSettingsEmailBlocklistSchema, +} from '@documenso/lib/server-only/site-settings/schemas/email-blocklist'; +import { trpc as trpcReact } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Switch } from '@documenso/ui/primitives/switch'; +import { Textarea } from '@documenso/ui/primitives/textarea'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { useForm } from 'react-hook-form'; +import { useRevalidator } from 'react-router'; +import { z } from 'zod'; + +const ZEmailBlocklistFormSchema = z.object({ + enabled: z.boolean(), + domains: z.string(), +}); + +type TEmailBlocklistFormSchema = z.infer; + +/** + * Splits a comma-separated string into a normalised list of domains. + * Normalisation (trim, lowercase, strip leading "@", dedupe) is applied + * server-side by the schema as well — this is for display consistency. + */ +const parseDomainsInput = (value: string): string[] => { + return Array.from( + new Set( + value + .split(',') + .map((entry) => entry.trim().toLowerCase().replace(/^@/, '')) + .filter((entry) => entry.length > 0), + ), + ); +}; + +type AdminEmailBlocklistSectionProps = { + emailBlocklist: TSiteSettingsEmailBlocklistSchema | undefined; +}; + +export const AdminEmailBlocklistSection = ({ emailBlocklist }: AdminEmailBlocklistSectionProps) => { + const { toast } = useToast(); + const { _ } = useLingui(); + const { revalidate } = useRevalidator(); + + const form = useForm({ + resolver: zodResolver(ZEmailBlocklistFormSchema), + defaultValues: { + enabled: emailBlocklist?.enabled ?? false, + domains: (emailBlocklist?.data?.domains ?? []).join(', '), + }, + }); + + const enabled = form.watch('enabled'); + + const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } = + trpcReact.admin.updateSiteSetting.useMutation(); + + const onBlocklistUpdate = async ({ enabled, domains }: TEmailBlocklistFormSchema) => { + try { + const parsedDomains = parseDomainsInput(domains); + + await updateSiteSetting({ + id: SITE_SETTINGS_EMAIL_BLOCKLIST_ID, + enabled, + data: { + domains: parsedDomains, + }, + }); + + // Reflect the normalised value back in the form. + form.reset({ + enabled, + domains: parsedDomains.join(', '), + }); + + toast({ + title: _(msg`Email Blocklist Updated`), + description: _(msg`The email blocklist has been updated successfully.`), + duration: 5000, + }); + + await revalidate(); + } catch (err) { + toast({ + title: _(msg`An unknown error occurred`), + variant: 'destructive', + description: _( + msg`We encountered an unknown error while attempting to update the email blocklist. Please try again later.`, + ), + }); + } + }; + + return ( +
+

+ Email Blocklist +

+

+ + Block signups from additional email domains on top of the bundled disposable email list. Subdomains are + matched automatically (e.g. blocking "bad.com" also blocks "foo.bad.com"). + +

+ +
+ + ( + + + Enabled + + + +
+ +
+
+
+ )} + /> + +
+ ( + + + Blocked Domains + + + +