EmailTemplates.tsx 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { PermissionAction } from '@supabase/shared-types/out/constants'
  3. import { useParams } from 'common'
  4. import { ChevronRight } from 'lucide-react'
  5. import Link from 'next/link'
  6. import { useEffect } from 'react'
  7. import { useForm } from 'react-hook-form'
  8. import { toast } from 'sonner'
  9. import { Button, Card, CardContent, CardFooter, Form, FormControl, FormField, Switch } from 'ui'
  10. import { Admonition } from 'ui-patterns/admonition'
  11. import {
  12. PageSection,
  13. PageSectionContent,
  14. PageSectionMeta,
  15. PageSectionSummary,
  16. PageSectionTitle,
  17. } from 'ui-patterns/PageSection'
  18. import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
  19. import * as z from 'zod'
  20. import { TEMPLATES_SCHEMAS } from './AuthTemplatesValidation'
  21. import { slugifyTitle } from './EmailTemplates.utils'
  22. import AlertError from '@/components/ui/AlertError'
  23. import { InlineLink } from '@/components/ui/InlineLink'
  24. import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
  25. import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
  26. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  27. import { DOCS_URL } from '@/lib/constants'
  28. const notificationEnabledKeys = TEMPLATES_SCHEMAS.filter(
  29. (t) => t.misc?.emailTemplateType === 'security'
  30. ).map((template) => {
  31. return `MAILER_NOTIFICATIONS_${template.id?.replace('_NOTIFICATION', '')}_ENABLED`
  32. })
  33. const NotificationsFormSchema = z.object({
  34. ...notificationEnabledKeys.reduce(
  35. (acc, key) => {
  36. acc[key] = z.boolean()
  37. return acc
  38. },
  39. {} as Record<string, z.ZodBoolean>
  40. ),
  41. })
  42. export const EmailTemplates = () => {
  43. const { ref: projectRef } = useParams()
  44. const { can: canUpdateConfig } = useAsyncCheckPermissions(
  45. PermissionAction.UPDATE,
  46. 'custom_config_gotrue'
  47. )
  48. const {
  49. data: authConfig,
  50. error: authConfigError,
  51. isPending: isLoading,
  52. isError,
  53. isSuccess,
  54. } = useAuthConfigQuery({ projectRef })
  55. const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useAuthConfigUpdateMutation({
  56. onError: (error) => {
  57. toast.error(`Failed to update settings: ${error?.message}`)
  58. },
  59. onSuccess: () => {
  60. toast.success('Successfully updated settings')
  61. },
  62. })
  63. const builtInSMTP =
  64. isSuccess &&
  65. authConfig &&
  66. (!authConfig.SMTP_HOST || !authConfig.SMTP_USER || !authConfig.SMTP_PASS)
  67. const defaultValues = notificationEnabledKeys.reduce(
  68. (acc, key) => {
  69. acc[key] = authConfig ? Boolean(authConfig[key as keyof typeof authConfig]) : false
  70. return acc
  71. },
  72. {} as Record<string, boolean>
  73. )
  74. const notificationsForm = useForm<z.infer<typeof NotificationsFormSchema>>({
  75. resolver: zodResolver(NotificationsFormSchema as any),
  76. defaultValues,
  77. })
  78. const onSubmit = (values: any) => {
  79. if (!projectRef) return console.error('Project ref is required')
  80. updateAuthConfig({ projectRef: projectRef, config: { ...values } })
  81. }
  82. useEffect(() => {
  83. if (authConfig) {
  84. notificationsForm.reset(defaultValues)
  85. }
  86. // eslint-disable-next-line react-hooks/exhaustive-deps
  87. }, [authConfig])
  88. return (
  89. <>
  90. {isError && (
  91. <PageSection>
  92. <PageSectionContent>
  93. <AlertError error={authConfigError} subject="Failed to retrieve auth configuration" />
  94. </PageSectionContent>
  95. </PageSection>
  96. )}
  97. {isLoading && (
  98. <PageSection>
  99. <PageSectionContent>
  100. <GenericSkeletonLoader />
  101. </PageSectionContent>
  102. </PageSection>
  103. )}
  104. {isSuccess && (
  105. <>
  106. <PageSection>
  107. {builtInSMTP && (
  108. <Admonition
  109. type="warning"
  110. title="Set up custom SMTP"
  111. description={
  112. <p>
  113. You’re using the built-in email service. This service has rate limits and is not
  114. meant to be used for production apps.{' '}
  115. <InlineLink
  116. href={`${DOCS_URL}/guides/platform/going-into-prod#auth-rate-limits`}
  117. >
  118. Learn more
  119. </InlineLink>{' '}
  120. </p>
  121. }
  122. layout="horizontal"
  123. className="mb-4"
  124. actions={
  125. <Button asChild type="default">
  126. <Link href={`/project/${projectRef}/auth/smtp`}>Set up SMTP</Link>
  127. </Button>
  128. }
  129. />
  130. )}
  131. <PageSectionMeta>
  132. <PageSectionSummary>
  133. <PageSectionTitle>Authentication</PageSectionTitle>
  134. </PageSectionSummary>
  135. </PageSectionMeta>
  136. <PageSectionContent>
  137. <Card>
  138. {TEMPLATES_SCHEMAS.filter(
  139. (t) => t.misc?.emailTemplateType === 'authentication'
  140. ).map((template) => {
  141. const templateSlug = slugifyTitle(template.title)
  142. return (
  143. <CardContent key={`${template.id}`} className="p-0">
  144. <Link
  145. href={`/project/${projectRef}/auth/templates/${templateSlug}`}
  146. className="flex items-center justify-between hover:bg-surface-200 transition-colors py-4 px-6 w-full h-full"
  147. >
  148. <div className="flex flex-col">
  149. <h3 className="text-sm text-foreground">{template.title}</h3>
  150. {template.purpose && (
  151. <p className="text-sm text-foreground-lighter">{template.purpose}</p>
  152. )}
  153. </div>
  154. <div className="flex items-center gap-4">
  155. <ChevronRight size={16} className="text-foreground-muted" />
  156. </div>
  157. </Link>
  158. </CardContent>
  159. )
  160. })}
  161. </Card>
  162. </PageSectionContent>
  163. </PageSection>
  164. <PageSection>
  165. <PageSectionMeta>
  166. <PageSectionSummary>
  167. <PageSectionTitle>Security</PageSectionTitle>
  168. </PageSectionSummary>
  169. </PageSectionMeta>
  170. <PageSectionContent>
  171. <Form {...notificationsForm}>
  172. <form onSubmit={notificationsForm.handleSubmit(onSubmit)} className="space-y-4">
  173. <Card>
  174. {TEMPLATES_SCHEMAS.filter((t) => t.misc?.emailTemplateType === 'security').map(
  175. (template) => {
  176. const templateSlug = slugifyTitle(template.title)
  177. const templateEnabledKey =
  178. `MAILER_NOTIFICATIONS_${template.id?.replace('_NOTIFICATION', '')}_ENABLED` as keyof typeof authConfig
  179. return (
  180. <CardContent
  181. key={`${template.id}`}
  182. className="p-0 flex items-center justify-between hover:bg-surface-200 transition-colors w-full h-full"
  183. >
  184. <Link
  185. href={`/project/${projectRef}/auth/templates/${templateSlug}`}
  186. className="flex flex-col flex-1 py-4 px-6"
  187. >
  188. <h3 className="text-sm text-foreground">{template.title}</h3>
  189. {template.purpose && (
  190. <p className="text-sm text-foreground-lighter">
  191. {template.purpose}
  192. </p>
  193. )}
  194. </Link>
  195. <div className="flex items-center gap-4 h-full pl-2 relative">
  196. <FormField
  197. control={notificationsForm.control}
  198. name={templateEnabledKey}
  199. render={({ field }) => (
  200. <FormControl>
  201. <Switch
  202. checked={field.value}
  203. onCheckedChange={field.onChange}
  204. disabled={!canUpdateConfig}
  205. />
  206. </FormControl>
  207. )}
  208. />
  209. <Link
  210. href={`/project/${projectRef}/auth/templates/${templateSlug}`}
  211. className="py-6 pr-6"
  212. >
  213. <ChevronRight size={16} className="text-foreground-muted" />
  214. </Link>
  215. </div>
  216. </CardContent>
  217. )
  218. }
  219. )}
  220. <CardFooter className="justify-end space-x-2">
  221. {notificationsForm.formState.isDirty && (
  222. <Button type="default" onClick={() => notificationsForm.reset()}>
  223. Cancel
  224. </Button>
  225. )}
  226. <Button
  227. type="primary"
  228. htmlType="submit"
  229. disabled={
  230. !canUpdateConfig ||
  231. isUpdatingConfig ||
  232. !notificationsForm.formState.isDirty
  233. }
  234. loading={isUpdatingConfig}
  235. >
  236. Save changes
  237. </Button>
  238. </CardFooter>
  239. </Card>
  240. </form>
  241. </Form>
  242. </PageSectionContent>
  243. </PageSection>
  244. </>
  245. )}
  246. </>
  247. )
  248. }