| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418 |
- 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<keyof AuthConfigResponse, `MAILER_SUBJECTS_${string}`>,
- '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<editor.IStandaloneCodeEditor | null>(null)
- const messageSlug = `MAILER_TEMPLATES_${id}_CONTENT` as EmailTemplateContentKey
- const { data: authConfig, isSuccess } = useAuthConfigQuery({ projectRef })
- const [validationResult, setValidationResult] = useState<ValidateSpamResponse>()
- 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<typeof template.validationSchema>) => {
- 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 (
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)}>
- <CardContent>
- {Object.keys(properties).map((x: string) => {
- const property = properties[x]
- if (property.type === 'string' && x !== messageSlug) {
- return (
- <FormField
- key={x}
- control={form.control}
- name={x}
- render={({ field }) => (
- <FormItemLayout
- className="gap-y-3"
- layout="vertical"
- label={property.title}
- description={
- property.description ? (
- <ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
- {property.description}
- </ReactMarkdown>
- ) : null
- }
- labelOptional={
- property.descriptionOptional ? (
- <ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
- {property.descriptionOptional}
- </ReactMarkdown>
- ) : null
- }
- >
- <FormControl>
- <Input id={x} {...field} disabled={!canUpdateConfig} />
- </FormControl>
- </FormItemLayout>
- )}
- />
- )
- }
- return null
- })}
- </CardContent>
- {messageProperty && (
- <>
- <CardContent className="flex flex-col gap-4">
- <div className="flex items-center justify-between gap-2">
- <Label>Body</Label>
- <TwoOptionToggle
- width={60}
- options={['preview', 'source']}
- activeOption={activeView}
- onClickOption={(option) => setActiveView(option as 'source' | 'preview')}
- borderOverride="border-muted"
- />
- </div>
- {activeView === 'source' ? (
- <>
- <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!">
- <CodeEditor
- id="code-id"
- language="html"
- isReadOnly={!canUpdateConfig}
- className="mb-0! relative h-96 outline-hidden outline-offset-0 outline-width-0 outline-0"
- onInputChange={(e: string | undefined) => {
- setBodyValue(e ?? '')
- if (bodyValue !== e) setHasUnsavedChanges(true)
- }}
- options={{ wordWrap: 'on', contextmenu: false, padding: { top: 16 } }}
- value={bodyValue}
- editorRef={editorRef}
- />
- </div>
- <div className="flex flex-col gap-y-2">
- <div className="flex flex-col">
- <p className="text-sm">Template variables</p>
- <p className="text-sm text-foreground-lighter">
- Data placeholders that can be inserted into the subject or body.{' '}
- <InlineLink
- href={`${DOCS_URL}/guides/local-development/customizing-email-templates#template-variables`}
- >
- Learn more
- </InlineLink>
- </p>
- </div>
- <div className="flex flex-wrap gap-x-1">
- {template.variables.map((variable) => (
- <Tooltip key={variable.value}>
- <TooltipTrigger asChild>
- <Button
- type="outline"
- size="tiny"
- className="rounded-full"
- onClick={() => insertTextAtCursor(variable.value)}
- >
- {variable.value}
- </Button>
- </TooltipTrigger>
- <TooltipContent side="bottom">
- {variable.description}
- {variable.name === 'Token' &&
- template.variables.some((x) => x.name === 'ConfirmationURL') && (
- <>
- , which can be used instead of{' '}
- <code className="text-code-inline">ConfirmationURL</code>
- </>
- )}
- {variable.name === 'SiteURL' && (
- <>
- {' '}
- as defined in{' '}
- <InlineLink href={`/project/${projectRef}/auth/url-configuration`}>
- URL Configuration
- </InlineLink>
- </>
- )}
- </TooltipContent>
- </Tooltip>
- ))}
- </div>
- </div>
- </>
- ) : (
- <>
- <iframe
- className="mb-0! mt-0 overflow-hidden h-96 w-full rounded-md border bg-white"
- title={id}
- srcDoc={bodyValue}
- sandbox="allow-scripts allow-forms"
- />
- <Admonition
- type="default"
- title="Email rendering may differ"
- description="The preview shown here may differ slightly from how your email appears in the recipient’s email client."
- />
- </>
- )}
- </CardContent>
- <SpamValidation spamRules={spamRules} />
- <CardFooter className="flex flex-row justify-between gap-2">
- {hasCustomTemplate && (
- <ResetTemplateDialog
- template={template}
- hasUnsavedChanges={hasChanges}
- onResetSuccess={(config: AuthConfigResponse) => {
- form.reset(getFormValuesFromConfig(config))
- setBodyValue((config && config[messageSlug]) ?? '')
- setValidationResult(undefined)
- setHasUnsavedChanges(false)
- }}
- />
- )}
- <div className="ml-auto flex flex-row gap-2">
- {hasChanges && (
- <Button
- type="default"
- htmlType="button"
- onClick={() => {
- form.reset(INITIAL_VALUES)
- setBodyValue((authConfig && authConfig[messageSlug]) ?? '')
- setHasUnsavedChanges(false)
- }}
- >
- Cancel
- </Button>
- )}
- <Button
- type="primary"
- htmlType="submit"
- disabled={!canUpdateConfig || isSavingTemplate || !hasChanges}
- loading={isSavingTemplate}
- >
- Save changes
- </Button>
- </div>
- </CardFooter>
- </>
- )}
- </form>
- <PreventNavigationOnUnsavedChanges hasChanges={hasChanges} />
- </Form>
- )
- }
|