TemplateEditor.tsx 15 KB

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