diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx index e67cb1ca492..0fb588f4594 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx @@ -6,6 +6,8 @@ import { FormPanel } from 'components/ui/Forms/FormPanel' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { + Card, + CardContent, ScrollArea, Tabs_Shadcn_, TabsContent_Shadcn_, @@ -37,20 +39,20 @@ const EmailTemplates = () => { )} {isLoading && ( -
+
)} {isSuccess && ( - <> +
{builtInSMTP ? (
) : null} - + - + {TEMPLATES_SCHEMAS.map((template) => { return ( @@ -59,18 +61,17 @@ const EmailTemplates = () => { ) })} - {TEMPLATES_SCHEMAS.map((template) => { const panelId = template.title.trim().replace(/\s+/g, '-') return ( - + ) })} - - + +
)}
) diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx index 648a3f075fe..3cd49de2d3c 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx @@ -1,17 +1,7 @@ import { Markdown } from 'components/interfaces/Markdown' -import InformationBox from 'components/ui/InformationBox' import { ValidateSpamResponse } from 'data/auth/validate-spam-mutation' -import { - cn, - Separator, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - WarningIcon, -} from 'ui' +import { Check, MailWarning } from 'lucide-react' +import { Separator, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' interface SpamValidationProps { validationResult?: ValidateSpamResponse @@ -26,23 +16,23 @@ export const SpamValidation = ({ validationResult }: SpamValidationProps) => { const hasSpamWarning = spamRules.length > 0 return ( - } - title={ - hasSpamWarning - ? 'Email has a high probability of being marked as spam and deliverability may be affected' - : 'Email content is unlikely to be marked as spam' - } - description={ - hasSpamWarning ? ( +
+
+ {hasSpamWarning ? ( + + ) : ( + + )} +
+
+
+ {hasSpamWarning + ? 'Email has a high probability of being marked as spam - review issues below to improve deliverability.' + : 'Email content is unlikely to be marked as spam'} +
+ {hasSpamWarning && ( <>
-

- {hasSpamWarning - ? ` Rectify the following issues to improve your email's deliverability in order of priority:` - : ` Address the following issues to improve your email's deliverability:`} -

@@ -68,8 +58,8 @@ export const SpamValidation = ({ validationResult }: SpamValidationProps) => { content="Spam validation is powered by [SpamAssassin](https://spamassassin.apache.org/doc.html). Full list of all available warnings can be found [here](https://gist.github.com/ychaouche/a2faff159c2a1fea16019156972c7f8b)." /> - ) : null - } - /> + )} + + ) } diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx index 5e1d5738d1a..34d72fd0e02 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx @@ -1,28 +1,39 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { Code, Monitor } from 'lucide-react' -import { useEffect, useMemo, useState } from 'react' +import { editor } from 'monaco-editor' +import { useEffect, useMemo, useRef, useState } from 'react' import ReactMarkdown from 'react-markdown' import { toast } from 'sonner' +import { useForm } from 'react-hook-form' import { useParams } from 'common' import CodeEditor from 'components/ui/CodeEditor/CodeEditor' -import { FormActions } from 'components/ui/Forms/FormActions' -import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' -import InformationBox from 'components/ui/InformationBox' 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 { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { FormSchema } from 'types' import { - Form, - Input, + Badge, + Button, + Card, + CardContent, + CardFooter, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + Input_Shadcn_, + Label_Shadcn_, Tabs_Shadcn_, TabsContent_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_, + Tooltip, + TooltipContent, + TooltipTrigger, } from 'ui' import { Admonition } from 'ui-patterns' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { SpamValidation } from './SpamValidation' interface TemplateEditorProps { @@ -33,6 +44,9 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => { const { ref: projectRef } = useParams() const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') + // Add a ref to the code editor + const editorRef = useRef() + // [Joshen] Error state is handled in the parent const { data: authConfig, isSuccess } = useAuthConfigQuery({ projectRef }) @@ -49,15 +63,6 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => { const { id, properties } = template - const formId = `auth-config-email-templates-${id}` - 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 messageSlug = `MAILER_TEMPLATES_${id}_CONTENT` as keyof typeof authConfig const messageProperty = properties[messageSlug] const builtInSMTP = @@ -74,7 +79,33 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => { const spamRules = (validationResult?.rules ?? []).filter((rule) => rule.score > 0) const preventSaveFromSpamCheck = builtInSMTP && spamRules.length > 0 - const onSubmit = (values: any, { resetForm }: any) => { + // Create form values + 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]) + + // Setup React Hook Form + const form = useForm({ + defaultValues: INITIAL_VALUES, + }) + + // 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]) + + const onSubmit = (values: any) => { if (!projectRef) return console.error('Project ref is required') setIsSavingTemplate(true) @@ -112,10 +143,6 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => { onSuccess: () => { setIsSavingTemplate(false) toast.success('Successfully updated settings') - resetForm({ - values: values, - initialValues: values, - }) setHasUnsavedChanges(false) // Reset the unsaved changes state }, } @@ -157,140 +184,202 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]) - return ( -
- {({ resetForm, values, initialValues }: any) => { - const message = (authConfig && authConfig[messageSlug]) ?? '' - const hasChanges = - JSON.stringify(values) !== JSON.stringify(initialValues) || message !== bodyValue + // Single useMemo hook to parse and prepare message variables + const messageVariables = useMemo(() => { + if (!messageProperty?.description) return [] - 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 hasFormChanges = JSON.stringify(formValues) !== JSON.stringify(INITIAL_VALUES) + const hasChanges = hasFormChanges || ((authConfig && authConfig[messageSlug]) ?? '') !== 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() + } + } + + 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 && ( <> - - - {Object.keys(properties).map((x: string) => { - const property = properties[x] - if (property.type === 'string') { - return ( -
- - - {property.description} - - ) : null - } - labelOptional={ - property.descriptionOptional ? ( - - {property.descriptionOptional} - - ) : null - } - disabled={!canUpdateConfig} - /> -
- ) - } - })} -
-
- - - {messageProperty && ( - <> -
- - {messageProperty.title} - - {messageProperty.description} - ) - } - /> -
- - - - - Source - - - - Preview - - - - { - setBodyValue(e ?? '') - if (bodyValue !== e) setHasUnsavedChanges(true) - }} - options={{ wordWrap: 'on', contextmenu: false }} - value={bodyValue} - /> - - - - -