import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import type { editor } from 'monaco-editor' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useForm } from 'react-hook-form' import ReactMarkdown from 'react-markdown' import { toast } from 'sonner' import { Button, CardContent, CardFooter, Form, FormControl, FormField, Input, Label, Tooltip, TooltipContent, TooltipTrigger, } from 'ui' import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import z from 'zod' import type { AuthTemplate } from './EmailTemplates.types' import { ResetTemplateDialog } from './ResetTemplateDialog' import { SpamValidation } from './SpamValidation' import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges' import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' import { InlineLink } from '@/components/ui/InlineLink' import { TwoOptionToggle } from '@/components/ui/TwoOptionToggle' import type { AuthConfigResponse } from '@/data/auth/auth-config-query' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' import { useValidateSpamMutation, ValidateSpamResponse } from '@/data/auth/validate-spam-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { DOCS_URL } from '@/lib/constants' interface TemplateEditorProps { template: AuthTemplate } type EmailTemplateContentKey = Extract< keyof AuthConfigResponse, `MAILER_TEMPLATES_${string}_CONTENT` > type EmailTemplateSubjectKey = Exclude< Extract, 'MAILER_SUBJECTS_CUSTOM_CONTENTS' > export const TemplateEditor = ({ template }: TemplateEditorProps) => { const { ref: projectRef } = useParams() const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'custom_config_gotrue' ) const { id, properties } = template const editorRef = useRef(null) const messageSlug = `MAILER_TEMPLATES_${id}_CONTENT` as EmailTemplateContentKey const { data: authConfig, isSuccess } = useAuthConfigQuery({ projectRef }) const [validationResult, setValidationResult] = useState() const [bodyValue, setBodyValue] = useState((authConfig && authConfig[messageSlug]) ?? '') const [, setHasUnsavedChanges] = useState(false) const [isSavingTemplate, setIsSavingTemplate] = useState(false) const [activeView, setActiveView] = useState<'source' | 'preview'>('source') const { mutate: validateSpam } = useValidateSpamMutation() const { mutate: updateAuthConfig } = useAuthConfigUpdateMutation({ onError: (error) => { setIsSavingTemplate(false) toast.error(`Failed to update email templates: ${error.message}`) }, }) const subjectSlug = Object.keys(properties).find((key) => key.startsWith('MAILER_SUBJECTS_')) as | EmailTemplateSubjectKey | undefined const messageProperty = properties[messageSlug] const builtInSMTP = isSuccess && authConfig && (!authConfig.SMTP_HOST || !authConfig.SMTP_USER || !authConfig.SMTP_PASS) const spamRules = (validationResult?.rules ?? []).filter((rule) => rule.score > 0) const getFormValuesFromConfig = useCallback( (config: AuthConfigResponse | undefined) => { const result: { [x: string]: string } = {} Object.keys(properties).forEach((key) => { result[key] = ((config && config[key as keyof typeof config]) ?? '') as string }) return result }, [properties] ) const INITIAL_VALUES = useMemo(() => { return getFormValuesFromConfig(authConfig) }, [authConfig, getFormValuesFromConfig]) const form = useForm({ defaultValues: INITIAL_VALUES, resolver: zodResolver(template.validationSchema as any), }) const onSubmit = (values: z.infer) => { if (!projectRef) return console.error('Project ref is required') setIsSavingTemplate(true) const payload = { ...values } // Because the template content uses the code editor which is not a form component // its state is kept separately from the form state, hence why we manually inject it here delete payload[messageSlug] if (messageProperty) payload[messageSlug] = bodyValue const [subjectKey] = Object.keys(properties) validateSpam( { projectRef, template: { subject: payload[subjectKey], content: payload[messageSlug], }, }, { onSuccess: (res) => { setValidationResult(res) const spamRules = (res?.rules ?? []).filter((rule) => rule.score > 0) const preventSaveFromSpamCheck = builtInSMTP && spamRules.length > 0 if (preventSaveFromSpamCheck) { setIsSavingTemplate(false) toast.error( 'Please rectify all spam warnings before saving while using the built-in email service' ) } else { updateAuthConfig( { projectRef: projectRef, config: payload }, { onSuccess: () => { setIsSavingTemplate(false) setHasUnsavedChanges(false) // Reset the unsaved changes state toast.success('Successfully updated email template') }, } ) } }, onError: () => setIsSavingTemplate(false), } ) } // Check if form values have changed const formValues = form.watch() const baselineValues = INITIAL_VALUES const baselineBodyValue = (authConfig && authConfig[messageSlug]) ?? '' const hasCustomTemplate = authConfig?.MAILER_TEMPLATES_CUSTOM_CONTENTS?.[messageSlug] === true || (subjectSlug !== undefined && authConfig?.MAILER_SUBJECTS_CUSTOM_CONTENTS?.[subjectSlug] === true) const hasFormChanges = JSON.stringify(formValues) !== JSON.stringify(baselineValues) const hasChanges = hasFormChanges || baselineBodyValue !== bodyValue // Function to insert text at cursor position const insertTextAtCursor = (text: string) => { if (!editorRef.current) return const editor = editorRef.current const selection = editor.getSelection() if (selection) { const range = { startLineNumber: selection.startLineNumber, startColumn: selection.startColumn, endLineNumber: selection.endLineNumber, endColumn: selection.endColumn, } editor.executeEdits('insert-variable', [ { range, text, forceMoveMarkers: true, }, ]) // Focus the editor after insertion editor.focus() } } // Update form values when authConfig changes useEffect(() => { if (authConfig) { form.reset(getFormValuesFromConfig(authConfig)) setBodyValue((authConfig && authConfig[messageSlug]) ?? '') } }, [authConfig, getFormValuesFromConfig, messageSlug, form]) useEffect(() => { if (projectRef && id && !!authConfig) { const [subjectKey] = Object.keys(properties) validateSpam({ projectRef, template: { subject: authConfig[subjectKey as keyof typeof authConfig] as string, content: authConfig[messageSlug], }, }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]) useEffect(() => { if (!hasChanges) setValidationResult(undefined) }, [hasChanges]) return (
{Object.keys(properties).map((x: string) => { const property = properties[x] if (property.type === 'string' && x !== messageSlug) { return ( ( {property.description} ) : null } labelOptional={ property.descriptionOptional ? ( {property.descriptionOptional} ) : null } > )} /> ) } return null })} {messageProperty && ( <>
setActiveView(option as 'source' | 'preview')} borderOverride="border-muted" />
{activeView === 'source' ? ( <>
{ setBodyValue(e ?? '') if (bodyValue !== e) setHasUnsavedChanges(true) }} options={{ wordWrap: 'on', contextmenu: false, padding: { top: 16 } }} value={bodyValue} editorRef={editorRef} />

Template variables

Data placeholders that can be inserted into the subject or body.{' '} Learn more

{template.variables.map((variable) => ( {variable.description} {variable.name === 'Token' && template.variables.some((x) => x.name === 'ConfirmationURL') && ( <> , which can be used instead of{' '} ConfirmationURL )} {variable.name === 'SiteURL' && ( <> {' '} as defined in{' '} URL Configuration )} ))}
) : ( <>