TemplateEditor.tsx418 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { PermissionAction } from '@supabase/shared-types/out/constants'
3import { useParams } from 'common'
4import type { editor } from 'monaco-editor'
5import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
6import { useForm } from 'react-hook-form'
7import ReactMarkdown from 'react-markdown'
8import { toast } from 'sonner'
9import {
10 Button,
11 CardContent,
12 CardFooter,
13 Form,
14 FormControl,
15 FormField,
16 Input,
17 Label,
18 Tooltip,
19 TooltipContent,
20 TooltipTrigger,
21} from 'ui'
22import { Admonition } from 'ui-patterns'
23import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
24import z from 'zod'
25
26import type { AuthTemplate } from './EmailTemplates.types'
27import { ResetTemplateDialog } from './ResetTemplateDialog'
28import { SpamValidation } from './SpamValidation'
29import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges'
30import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
31import { InlineLink } from '@/components/ui/InlineLink'
32import { TwoOptionToggle } from '@/components/ui/TwoOptionToggle'
33import type { AuthConfigResponse } from '@/data/auth/auth-config-query'
34import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
35import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
36import { useValidateSpamMutation, ValidateSpamResponse } from '@/data/auth/validate-spam-mutation'
37import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
38import { DOCS_URL } from '@/lib/constants'
39
40interface TemplateEditorProps {
41 template: AuthTemplate
42}
43
44type EmailTemplateContentKey = Extract<
45 keyof AuthConfigResponse,
46 `MAILER_TEMPLATES_${string}_CONTENT`
47>
48type EmailTemplateSubjectKey = Exclude<
49 Extract<keyof AuthConfigResponse, `MAILER_SUBJECTS_${string}`>,
50 'MAILER_SUBJECTS_CUSTOM_CONTENTS'
51>
52
53export const TemplateEditor = ({ template }: TemplateEditorProps) => {
54 const { ref: projectRef } = useParams()
55 const { can: canUpdateConfig } = useAsyncCheckPermissions(
56 PermissionAction.UPDATE,
57 'custom_config_gotrue'
58 )
59
60 const { id, properties } = template
61 const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
62 const messageSlug = `MAILER_TEMPLATES_${id}_CONTENT` as EmailTemplateContentKey
63
64 const { data: authConfig, isSuccess } = useAuthConfigQuery({ projectRef })
65
66 const [validationResult, setValidationResult] = useState<ValidateSpamResponse>()
67 const [bodyValue, setBodyValue] = useState((authConfig && authConfig[messageSlug]) ?? '')
68 const [, setHasUnsavedChanges] = useState(false)
69 const [isSavingTemplate, setIsSavingTemplate] = useState(false)
70 const [activeView, setActiveView] = useState<'source' | 'preview'>('source')
71
72 const { mutate: validateSpam } = useValidateSpamMutation()
73
74 const { mutate: updateAuthConfig } = useAuthConfigUpdateMutation({
75 onError: (error) => {
76 setIsSavingTemplate(false)
77 toast.error(`Failed to update email templates: ${error.message}`)
78 },
79 })
80
81 const subjectSlug = Object.keys(properties).find((key) => key.startsWith('MAILER_SUBJECTS_')) as
82 | EmailTemplateSubjectKey
83 | undefined
84
85 const messageProperty = properties[messageSlug]
86 const builtInSMTP =
87 isSuccess &&
88 authConfig &&
89 (!authConfig.SMTP_HOST || !authConfig.SMTP_USER || !authConfig.SMTP_PASS)
90
91 const spamRules = (validationResult?.rules ?? []).filter((rule) => rule.score > 0)
92
93 const getFormValuesFromConfig = useCallback(
94 (config: AuthConfigResponse | undefined) => {
95 const result: { [x: string]: string } = {}
96 Object.keys(properties).forEach((key) => {
97 result[key] = ((config && config[key as keyof typeof config]) ?? '') as string
98 })
99 return result
100 },
101 [properties]
102 )
103
104 const INITIAL_VALUES = useMemo(() => {
105 return getFormValuesFromConfig(authConfig)
106 }, [authConfig, getFormValuesFromConfig])
107
108 const form = useForm({
109 defaultValues: INITIAL_VALUES,
110 resolver: zodResolver(template.validationSchema as any),
111 })
112
113 const onSubmit = (values: z.infer<typeof template.validationSchema>) => {
114 if (!projectRef) return console.error('Project ref is required')
115
116 setIsSavingTemplate(true)
117
118 const payload = { ...values }
119
120 // Because the template content uses the code editor which is not a form component
121 // its state is kept separately from the form state, hence why we manually inject it here
122 delete payload[messageSlug]
123 if (messageProperty) payload[messageSlug] = bodyValue
124
125 const [subjectKey] = Object.keys(properties)
126
127 validateSpam(
128 {
129 projectRef,
130 template: {
131 subject: payload[subjectKey],
132 content: payload[messageSlug],
133 },
134 },
135 {
136 onSuccess: (res) => {
137 setValidationResult(res)
138 const spamRules = (res?.rules ?? []).filter((rule) => rule.score > 0)
139 const preventSaveFromSpamCheck = builtInSMTP && spamRules.length > 0
140
141 if (preventSaveFromSpamCheck) {
142 setIsSavingTemplate(false)
143 toast.error(
144 'Please rectify all spam warnings before saving while using the built-in email service'
145 )
146 } else {
147 updateAuthConfig(
148 { projectRef: projectRef, config: payload },
149 {
150 onSuccess: () => {
151 setIsSavingTemplate(false)
152 setHasUnsavedChanges(false) // Reset the unsaved changes state
153 toast.success('Successfully updated email template')
154 },
155 }
156 )
157 }
158 },
159 onError: () => setIsSavingTemplate(false),
160 }
161 )
162 }
163
164 // Check if form values have changed
165 const formValues = form.watch()
166 const baselineValues = INITIAL_VALUES
167 const baselineBodyValue = (authConfig && authConfig[messageSlug]) ?? ''
168 const hasCustomTemplate =
169 authConfig?.MAILER_TEMPLATES_CUSTOM_CONTENTS?.[messageSlug] === true ||
170 (subjectSlug !== undefined &&
171 authConfig?.MAILER_SUBJECTS_CUSTOM_CONTENTS?.[subjectSlug] === true)
172 const hasFormChanges = JSON.stringify(formValues) !== JSON.stringify(baselineValues)
173 const hasChanges = hasFormChanges || baselineBodyValue !== bodyValue
174
175 // Function to insert text at cursor position
176 const insertTextAtCursor = (text: string) => {
177 if (!editorRef.current) return
178
179 const editor = editorRef.current
180 const selection = editor.getSelection()
181
182 if (selection) {
183 const range = {
184 startLineNumber: selection.startLineNumber,
185 startColumn: selection.startColumn,
186 endLineNumber: selection.endLineNumber,
187 endColumn: selection.endColumn,
188 }
189
190 editor.executeEdits('insert-variable', [
191 {
192 range,
193 text,
194 forceMoveMarkers: true,
195 },
196 ])
197
198 // Focus the editor after insertion
199 editor.focus()
200 }
201 }
202
203 // Update form values when authConfig changes
204 useEffect(() => {
205 if (authConfig) {
206 form.reset(getFormValuesFromConfig(authConfig))
207 setBodyValue((authConfig && authConfig[messageSlug]) ?? '')
208 }
209 }, [authConfig, getFormValuesFromConfig, messageSlug, form])
210
211 useEffect(() => {
212 if (projectRef && id && !!authConfig) {
213 const [subjectKey] = Object.keys(properties)
214
215 validateSpam({
216 projectRef,
217 template: {
218 subject: authConfig[subjectKey as keyof typeof authConfig] as string,
219 content: authConfig[messageSlug],
220 },
221 })
222 }
223 // eslint-disable-next-line react-hooks/exhaustive-deps
224 }, [id])
225
226 useEffect(() => {
227 if (!hasChanges) setValidationResult(undefined)
228 }, [hasChanges])
229
230 return (
231 <Form {...form}>
232 <form onSubmit={form.handleSubmit(onSubmit)}>
233 <CardContent>
234 {Object.keys(properties).map((x: string) => {
235 const property = properties[x]
236 if (property.type === 'string' && x !== messageSlug) {
237 return (
238 <FormField
239 key={x}
240 control={form.control}
241 name={x}
242 render={({ field }) => (
243 <FormItemLayout
244 className="gap-y-3"
245 layout="vertical"
246 label={property.title}
247 description={
248 property.description ? (
249 <ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
250 {property.description}
251 </ReactMarkdown>
252 ) : null
253 }
254 labelOptional={
255 property.descriptionOptional ? (
256 <ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
257 {property.descriptionOptional}
258 </ReactMarkdown>
259 ) : null
260 }
261 >
262 <FormControl>
263 <Input id={x} {...field} disabled={!canUpdateConfig} />
264 </FormControl>
265 </FormItemLayout>
266 )}
267 />
268 )
269 }
270 return null
271 })}
272 </CardContent>
273
274 {messageProperty && (
275 <>
276 <CardContent className="flex flex-col gap-4">
277 <div className="flex items-center justify-between gap-2">
278 <Label>Body</Label>
279 <TwoOptionToggle
280 width={60}
281 options={['preview', 'source']}
282 activeOption={activeView}
283 onClickOption={(option) => setActiveView(option as 'source' | 'preview')}
284 borderOverride="border-muted"
285 />
286 </div>
287 {activeView === 'source' ? (
288 <>
289 <div className="overflow-hidden rounded-md border dark:border-control overflow-hidden [&_.monaco-editor]:outline-0 [&_.monaco-editor-background]:bg-surface-200/30! [&_.monaco-editor_.margin]:bg-surface-200/30! dark:[&_.monaco-editor-background]:bg-surface-300! dark:[&_.monaco-editor_.margin]:bg-surface-300!">
290 <CodeEditor
291 id="code-id"
292 language="html"
293 isReadOnly={!canUpdateConfig}
294 className="mb-0! relative h-96 outline-hidden outline-offset-0 outline-width-0 outline-0"
295 onInputChange={(e: string | undefined) => {
296 setBodyValue(e ?? '')
297 if (bodyValue !== e) setHasUnsavedChanges(true)
298 }}
299 options={{ wordWrap: 'on', contextmenu: false, padding: { top: 16 } }}
300 value={bodyValue}
301 editorRef={editorRef}
302 />
303 </div>
304
305 <div className="flex flex-col gap-y-2">
306 <div className="flex flex-col">
307 <p className="text-sm">Template variables</p>
308 <p className="text-sm text-foreground-lighter">
309 Data placeholders that can be inserted into the subject or body.{' '}
310 <InlineLink
311 href={`${DOCS_URL}/guides/local-development/customizing-email-templates#template-variables`}
312 >
313 Learn more
314 </InlineLink>
315 </p>
316 </div>
317 <div className="flex flex-wrap gap-x-1">
318 {template.variables.map((variable) => (
319 <Tooltip key={variable.value}>
320 <TooltipTrigger asChild>
321 <Button
322 type="outline"
323 size="tiny"
324 className="rounded-full"
325 onClick={() => insertTextAtCursor(variable.value)}
326 >
327 {variable.value}
328 </Button>
329 </TooltipTrigger>
330 <TooltipContent side="bottom">
331 {variable.description}
332
333 {variable.name === 'Token' &&
334 template.variables.some((x) => x.name === 'ConfirmationURL') && (
335 <>
336 , which can be used instead of{' '}
337 <code className="text-code-inline">ConfirmationURL</code>
338 </>
339 )}
340
341 {variable.name === 'SiteURL' && (
342 <>
343 {' '}
344 as defined in{' '}
345 <InlineLink href={`/project/${projectRef}/auth/url-configuration`}>
346 URL Configuration
347 </InlineLink>
348 </>
349 )}
350 </TooltipContent>
351 </Tooltip>
352 ))}
353 </div>
354 </div>
355 </>
356 ) : (
357 <>
358 <iframe
359 className="mb-0! mt-0 overflow-hidden h-96 w-full rounded-md border bg-white"
360 title={id}
361 srcDoc={bodyValue}
362 sandbox="allow-scripts allow-forms"
363 />
364 <Admonition
365 type="default"
366 title="Email rendering may differ"
367 description="The preview shown here may differ slightly from how your email appears in the recipient’s email client."
368 />
369 </>
370 )}
371 </CardContent>
372
373 <SpamValidation spamRules={spamRules} />
374
375 <CardFooter className="flex flex-row justify-between gap-2">
376 {hasCustomTemplate && (
377 <ResetTemplateDialog
378 template={template}
379 hasUnsavedChanges={hasChanges}
380 onResetSuccess={(config: AuthConfigResponse) => {
381 form.reset(getFormValuesFromConfig(config))
382 setBodyValue((config && config[messageSlug]) ?? '')
383 setValidationResult(undefined)
384 setHasUnsavedChanges(false)
385 }}
386 />
387 )}
388 <div className="ml-auto flex flex-row gap-2">
389 {hasChanges && (
390 <Button
391 type="default"
392 htmlType="button"
393 onClick={() => {
394 form.reset(INITIAL_VALUES)
395 setBodyValue((authConfig && authConfig[messageSlug]) ?? '')
396 setHasUnsavedChanges(false)
397 }}
398 >
399 Cancel
400 </Button>
401 )}
402 <Button
403 type="primary"
404 htmlType="submit"
405 disabled={!canUpdateConfig || isSavingTemplate || !hasChanges}
406 loading={isSavingTemplate}
407 >
408 Save changes
409 </Button>
410 </div>
411 </CardFooter>
412 </>
413 )}
414 </form>
415 <PreventNavigationOnUnsavedChanges hasChanges={hasChanges} />
416 </Form>
417 )
418}