Files
supabase/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx
Gildas Garcia 87581e02cb fix: Prevent users from loosing changes when navigating away (#43577)
## Problem

When editing email templates or edge functions, users may navigate away
from the page without a warning indicating they may loose their changes.

This is because we only handle the `beforeunload` event when we should
also handle NextJS routing events.
This is actually done for the observability reports.

## Solution

Extract the logic from the observability reports into a reusable
component and use it where needed

## How to test

On staging, for each case:
- Authentication email templates
- Observability reports
- Edge functions creation
- Edge functions edition

Test:
- Modify the template/report/function
- Navigate away using either the sidebar link, the browser back button
or closing the tab
- Cancel navigation in the confirmation dialog
- Navigation should be prevented and you should not loose your changes

Test:
- Modify the template/report/function
- Navigate away using either the sidebar link, the browser back button
or closing the tab
- Confirm navigation in the confirmation dialog
- Navigation should not be prevented and you should have lost your
changes
2026-03-10 15:10:47 +01:00

369 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { PermissionAction } from '@supabase/shared-types/out/constants'
import type { editor } from 'monaco-editor'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import ReactMarkdown from 'react-markdown'
import { toast } from 'sonner'
import { useParams } from 'common'
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
import TwoOptionToggle from 'components/ui/TwoOptionToggle'
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 type { FormSchema } from 'types'
import {
Button,
CardContent,
CardFooter,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
Label_Shadcn_,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { SpamValidation } from './SpamValidation'
import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges'
interface TemplateEditorProps {
template: FormSchema
}
export const TemplateEditor = ({ template }: TemplateEditorProps) => {
const { ref: projectRef } = useParams()
const { can: canUpdateConfig } = useAsyncCheckPermissions(
PermissionAction.UPDATE,
'custom_config_gotrue'
)
const editorRef = useRef<editor.IStandaloneCodeEditor>()
// [Joshen] Error state is handled in the parent
const { data: authConfig, isSuccess } = useAuthConfigQuery({ projectRef })
const { mutate: validateSpam } = useValidateSpamMutation()
const { mutate: updateAuthConfig } = useAuthConfigUpdateMutation({
onError: (error) => {
setIsSavingTemplate(false)
toast.error(`Failed to update email templates: ${error.message}`)
},
})
const { id, properties } = template
const messageSlug = `MAILER_TEMPLATES_${id}_CONTENT` as keyof typeof authConfig
const messageProperty = properties[messageSlug]
const builtInSMTP =
isSuccess &&
authConfig &&
(!authConfig.SMTP_HOST || !authConfig.SMTP_USER || !authConfig.SMTP_PASS)
const [validationResult, setValidationResult] = useState<ValidateSpamResponse>()
const [bodyValue, setBodyValue] = useState((authConfig && authConfig[messageSlug]) ?? '')
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const [isSavingTemplate, setIsSavingTemplate] = useState(false)
const [activeView, setActiveView] = useState<'source' | 'preview'>('source')
const spamRules = (validationResult?.rules ?? []).filter((rule) => rule.score > 0)
const INITIAL_VALUES = useMemo(() => {
const result: { [x: string]: string } = {}
Object.keys(properties).forEach((key) => {
result[key] = ((authConfig && authConfig[key as keyof typeof authConfig]) ?? '') as string
})
return result
}, [authConfig, properties])
const form = useForm({ defaultValues: INITIAL_VALUES })
const onSubmit = (values: any) => {
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),
}
)
}
// Single useMemo hook to parse and prepare message variables
const messageVariables = useMemo(() => {
if (!messageProperty?.description) return []
// Parse bullet point format: - `{{ .Variable }}` : Description
const lines = messageProperty.description.split('\n')
const variables: { variable: string; description: string }[] = []
for (const line of lines) {
// Match lines that start with a bullet point followed by a variable in the format {{ .Variable }}
// Handle variations in formatting (with or without backticks, different spacing)
const match = line.match(/-\s*`?({{\s*\.\w+\s*}})`?\s*(?::|-)?\s*(.+)/)
if (match && match[1] && match[2]) {
variables.push({
variable: match[1].replace(/`/g, '').trim(),
description: match[2].trim(),
})
}
}
return variables
}, [messageProperty?.description])
// Check if form values have changed
const formValues = form.watch()
const baselineValues = INITIAL_VALUES
const baselineBodyValue = (authConfig && authConfig[messageSlug]) ?? ''
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) {
const values: { [key: string]: string } = {}
Object.keys(properties).forEach((key) => {
values[key] = ((authConfig && authConfig[key as keyof typeof authConfig]) ?? '') as string
})
form.reset(values)
setBodyValue((authConfig && authConfig[messageSlug]) ?? '')
}
}, [authConfig, properties, 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_Shadcn_ {...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_Shadcn_
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_Shadcn_>
<Input_Shadcn_ id={x} {...field} disabled={!canUpdateConfig} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)
}
return null
})}
</CardContent>
{messageProperty && (
<>
<CardContent className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-2">
<Label_Shadcn_>Body</Label_Shadcn_>
<TwoOptionToggle
width={60}
options={['preview', 'source']}
activeOption={activeView}
onClickOption={(option: 'source' | 'preview') => setActiveView(option)}
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-none 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>
{messageVariables.length > 0 && (
<div className="flex flex-wrap gap-1">
{messageVariables.map(({ variable, description }) => (
<Tooltip key={variable}>
<TooltipTrigger asChild>
<Button
type="outline"
size="tiny"
className="rounded-full"
onClick={() => insertTextAtCursor(variable)}
>
{variable}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>{description || 'Variable description not available'}</p>
</TooltipContent>
</Tooltip>
))}
</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-end gap-2">
{hasChanges && (
<Button
type="default"
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>
</CardFooter>
</>
)}
</form>
<PreventNavigationOnUnsavedChanges hasChanges={hasChanges} />
</Form_Shadcn_>
)
}