diff --git a/apps/www/app/api-v2/opt-out/[ref]/route.ts b/apps/www/app/api-v2/opt-out/[ref]/route.ts new file mode 100644 index 00000000000..15456e39ba6 --- /dev/null +++ b/apps/www/app/api-v2/opt-out/[ref]/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@supabase/supabase-js' + +const supabaseUrl = process.env.NEXT_PUBLIC_EMAIL_ABUSE_URL as string +const supabaseServiceKey = process.env.EMAIL_ABUSE_SERVICE_KEY as string + +export async function POST(req: NextRequest, { params }: { params: { ref: string } }) { + const ref = params.ref + const { reason, email } = await req.json() + + const supabase = createClient(supabaseUrl, supabaseServiceKey) + + if (!ref) { + return NextResponse.json( + { error: 'Bad Request: Missing or invalid project reference.' }, + { status: 400 } + ) + } + + const refPattern = /^[a-zA-Z]{20}$/ + const refIsInvalid = !refPattern.test(ref) + + if (refIsInvalid) { + return NextResponse.json( + { error: 'Bad Request: Missing or invalid project reference.' }, + { status: 400 } + ) + } + + try { + const { error: supabaseError } = await supabase + .from('manual_reports') + .insert([{ project_ref: ref, reason, email }]) + + if (supabaseError) throw new Error(`Supabase error: ${supabaseError.message}`) + + const response = await fetch(process.env.EMAIL_REPORT_SLACK_WEBHOOK as string, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: `New report from: ${ref} \n\n ${reason}` }), + }) + + if (!response.ok) throw new Error('Failed to send to Slack') + + return NextResponse.json( + { message: 'Thank you! We have received your report.' }, + { status: 200 } + ) + } catch (error) { + const errorMessage = (error as Error).message + return NextResponse.json( + { error: `Failure: Could not send post to Slack. Error: ${errorMessage}` }, + { status: 500 } + ) + } +} diff --git a/apps/www/pages/opt-out/[ref].tsx b/apps/www/pages/opt-out/[ref].tsx new file mode 100644 index 00000000000..b164d18390b --- /dev/null +++ b/apps/www/pages/opt-out/[ref].tsx @@ -0,0 +1,196 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import Layout from 'components/Layouts/Default' +import { NextSeo } from 'next-seo' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { + Button, + cn, + Form_Shadcn_, + FormField_Shadcn_, + FormMessage_Shadcn_, + Select_Shadcn_, + SelectContent_Shadcn_, + SelectGroup_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, +} from 'ui' +import { Admonition } from 'ui-patterns' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { z } from 'zod' + +const formSchema = z.object({ + ref: z.string(), + reason: z.string().optional(), +}) + +export default function OptOutPage() { + const router = useRouter() + const ref = router.query.ref as string + const email = router.query.email as string | undefined + + const [formMessage, setFormMessage] = useState(null) + const [submissionType, setSubmissionType] = useState<'success' | 'error' | null>(null) + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + ref: ref, + reason: undefined, + }, + }) + + const { register } = form + + // Ensure ref is a string + const refString = typeof ref !== 'string' + const refPattern = /^[a-zA-Z]{20}$/ + const refIsInvalid = !refString && !refPattern.test(ref) + const SelectItemContainerClasses = 'flex flex-col text-sm items-start' + + const onSubmit = async (data: z.infer) => { + try { + setSubmissionType(null) + setFormMessage('Submitting...') + + const response = await fetch(`/api-v2/opt-out/${data.ref}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ reason: data.reason, email: email }), + }) + const message = await response.text() + + if (refIsInvalid) { + setFormMessage('Error: Invalid project reference.') + setSubmissionType('error') + } else if (response.ok) { + setFormMessage(message) + setSubmissionType('success') + } else { + console.error('Form submission failed:', message) + setFormMessage('Error: Form submission failed.') + setSubmissionType('error') + } + } catch (error) { + console.error('Error submitting form:', error) + setFormMessage('Error: An unexpected error occurred.') + setSubmissionType('error') + } + } + + function OptOutLayout({ children }: { children: React.ReactNode }) { + return ( + <> + + + {children} + + + ) + } + + useEffect(() => { + form.reset({ ref: ref, reason: '' }) + }, [ref]) + + return ( + <> + +
+ Opt out +

Receive an unexpected email from Supabase?

+

Please report it here so we can investigate.

+
+ + {refIsInvalid && ( + +

The project reference you entered is invalid.

+

Please check the URL and try again.

+
+ )} + + +
+ + ( + + + + + {field.value} + + + + + +
+ Phishing + + Attempt to obtain sensitive information + +
+
+ +
+ Advertisement + + Unwanted promotional content + +
+
+ +
+ Malware + + Contains harmful software + +
+
+ +
+ Scam + + Fraudulent or deceptive content + +
+
+ +
+ Other + + Any other type of spam + +
+
+
+
+
+
+ )} + /> + + + {formMessage} + + +
+
+ + ) +} diff --git a/apps/www/pages/opt-out/index.tsx b/apps/www/pages/opt-out/index.tsx new file mode 100644 index 00000000000..690ff9449b5 --- /dev/null +++ b/apps/www/pages/opt-out/index.tsx @@ -0,0 +1,15 @@ +import { useEffect } from 'react' +import { useRouter } from 'next/router' + +const OptOutIndex = () => { + const router = useRouter() + + // We don't want anyone landing here directly + useEffect(() => { + router.push('/') + }, [router]) + + return null +} + +export default OptOutIndex diff --git a/packages/ui/src/components/shadcn/ui/select.tsx b/packages/ui/src/components/shadcn/ui/select.tsx index 8bc84d8b5f8..8fd409dbcae 100644 --- a/packages/ui/src/components/shadcn/ui/select.tsx +++ b/packages/ui/src/components/shadcn/ui/select.tsx @@ -47,7 +47,7 @@ const SelectTrigger = React.forwardRef<