diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailBrandingSheet.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailBrandingSheet.tsx index 3d024d54f83..5d6b363a101 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailBrandingSheet.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailBrandingSheet.tsx @@ -1,9 +1,18 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useParams } from 'common' -import { useEffect, useMemo, useRef } from 'react' +import { Upload } from 'lucide-react' +import { useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, Button, Form_Shadcn_, FormControl_Shadcn_, @@ -23,6 +32,12 @@ import * as z from 'zod' import { useAuthBrandingQuery } from '@/data/auth/auth-branding-query' import { useAuthBrandingUpdateMutation } from '@/data/auth/auth-branding-update-mutation' +import { useProjectApiUrl } from '@/data/config/project-endpoint-query' +import { get } from '@/data/fetchers' +import { useBucketCreateMutation } from '@/data/storage/bucket-create-mutation' +import { createProjectSupabaseClient } from '@/lib/project-supabase-client' + +const BUCKET_NAME = 'email_assets' interface EmailBrandingSheetProps { visible: boolean @@ -67,7 +82,7 @@ function generatePreviewHtml(values: BrandingFormValues): string { const footerText = values.brand_footer_text || `© ${new Date().getFullYear()} ${name}` const headerContent = values.brand_logo_url - ? `${name}` + ? `${name}` : `${name}` return ` @@ -95,9 +110,23 @@ function generatePreviewHtml(values: BrandingFormValues): string { ` } +async function bucketExists(projectRef: string): Promise { + const { error } = await get('/platform/storage/{ref}/buckets/{id}', { + params: { path: { ref: projectRef, id: BUCKET_NAME } }, + }) + return !error +} + export function EmailBrandingSheet({ visible, onClose }: EmailBrandingSheetProps) { const { ref: projectRef } = useParams() const colorInputRef = useRef(null) + const logoUploadRef = useRef(null) + + const [isUploadingLogo, setIsUploadingLogo] = useState(false) + const [pendingFile, setPendingFile] = useState(null) + const [showBucketWarning, setShowBucketWarning] = useState(false) + + const { data: clientEndpoint } = useProjectApiUrl({ projectRef }) const { data: branding, isPending: isLoading } = useAuthBrandingQuery( { projectRef }, @@ -114,6 +143,8 @@ export function EmailBrandingSheet({ visible, onClose }: EmailBrandingSheetProps }, }) + const { mutate: createBucket, isPending: isCreatingBucket } = useBucketCreateMutation() + const form = useForm({ resolver: zodResolver(BrandingFormSchema), defaultValues: { @@ -136,9 +167,66 @@ export function EmailBrandingSheet({ visible, onClose }: EmailBrandingSheetProps }, [branding, form]) const watchedValues = form.watch() - const previewHtml = useMemo(() => generatePreviewHtml(watchedValues), [watchedValues]) + const uploadFile = async (file: File) => { + if (!projectRef || !clientEndpoint) return + setIsUploadingLogo(true) + try { + const client = await createProjectSupabaseClient(projectRef, clientEndpoint) + const ext = file.name.split('.').pop() ?? 'png' + const path = `logo-${Date.now()}.${ext}` + const { error: uploadError } = await client.storage + .from(BUCKET_NAME) + .upload(path, file, { upsert: true }) + if (uploadError) throw uploadError + const { + data: { publicUrl }, + } = client.storage.from(BUCKET_NAME).getPublicUrl(path) + form.setValue('brand_logo_url', publicUrl, { shouldDirty: true }) + } catch (err) { + toast.error(`Failed to upload logo: ${err instanceof Error ? err.message : 'Unknown error'}`) + } finally { + setIsUploadingLogo(false) + setPendingFile(null) + } + } + + const handleFileSelect = async (e: ChangeEvent) => { + const file = e.target.files?.[0] + // Reset so the same file can be re-selected after a cancel + e.target.value = '' + if (!file || !projectRef) return + + const exists = await bucketExists(projectRef) + if (exists) { + uploadFile(file) + } else { + setPendingFile(file) + setShowBucketWarning(true) + } + } + + const handleConfirmBucketCreate = () => { + if (!pendingFile || !projectRef) return + setShowBucketWarning(false) + createBucket( + { projectRef, id: BUCKET_NAME, type: 'STANDARD', isPublic: true }, + { + onSuccess: () => uploadFile(pendingFile), + onError: (err) => { + toast.error(`Failed to create bucket: ${err.message}`) + setPendingFile(null) + }, + } + ) + } + + const handleCancelBucketCreate = () => { + setShowBucketWarning(false) + setPendingFile(null) + } + const onSubmit = (values: BrandingFormValues) => { if (!projectRef) return updateBranding({ projectRef, branding: values }) @@ -151,184 +239,234 @@ export function EmailBrandingSheet({ visible, onClose }: EmailBrandingSheetProps } } + const isWorking = isUploadingLogo || isCreatingBucket + return ( - - - - Customize email branding - + <> + + + + Customize email branding + - - {isLoading ? ( - - ) : ( -
- {/* Form panel */} -
-

- These branding attributes are applied to all default email templates. Set up{' '} - custom SMTP to edit raw template HTML. -

+ + {isLoading ? ( + + ) : ( +
+ {/* Form panel */} +
+

+ These branding attributes are applied to all default email templates. Set up{' '} + custom SMTP to edit raw template HTML. +

- -
- ( - - - - - - )} - /> - - ( - - - - - - )} - /> - - ( - - -
- {/* Native color picker swatch */} -
-
colorInputRef.current?.click()} - /> - field.onChange(e.target.value)} - /> -
+ + + ( + + -
- - - )} - /> + + + )} + /> - ( - - -