diff --git a/src/components/settings/AccountDeletionDialog.tsx b/src/components/settings/AccountDeletionDialog.tsx new file mode 100644 index 00000000..504b2c58 --- /dev/null +++ b/src/components/settings/AccountDeletionDialog.tsx @@ -0,0 +1,282 @@ +import { useState } from 'react'; +import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { supabase } from '@/integrations/supabase/client'; +import { useToast } from '@/hooks/use-toast'; +import { Loader2, AlertTriangle, Info } from 'lucide-react'; + +interface AccountDeletionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + userEmail: string; + onDeletionRequested: () => void; +} + +export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletionRequested }: AccountDeletionDialogProps) { + const [step, setStep] = useState<'warning' | 'confirm' | 'code'>('warning'); + const [loading, setLoading] = useState(false); + const [confirmationCode, setConfirmationCode] = useState(''); + const [codeReceived, setCodeReceived] = useState(false); + const [scheduledDate, setScheduledDate] = useState(''); + const { toast } = useToast(); + + const handleRequestDeletion = async () => { + setLoading(true); + try { + const { data, error } = await supabase.functions.invoke('request-account-deletion', { + body: {}, + }); + + if (error) throw error; + + setScheduledDate(data.scheduled_deletion_at); + setStep('code'); + onDeletionRequested(); + + toast({ + title: 'Deletion Requested', + description: 'Check your email for the confirmation code.', + }); + } catch (error: any) { + toast({ + variant: 'destructive', + title: 'Error', + description: error.message || 'Failed to request account deletion', + }); + } finally { + setLoading(false); + } + }; + + const handleConfirmDeletion = async () => { + if (!confirmationCode || confirmationCode.length !== 6) { + toast({ + variant: 'destructive', + title: 'Invalid Code', + description: 'Please enter a 6-digit confirmation code', + }); + return; + } + + setLoading(true); + try { + const { error } = await supabase.functions.invoke('confirm-account-deletion', { + body: { confirmation_code: confirmationCode }, + }); + + if (error) throw error; + + toast({ + title: 'Account Deleted', + description: 'Your account has been permanently deleted.', + }); + + // Sign out and redirect + await supabase.auth.signOut(); + window.location.href = '/'; + } catch (error: any) { + toast({ + variant: 'destructive', + title: 'Error', + description: error.message || 'Failed to confirm deletion', + }); + } finally { + setLoading(false); + } + }; + + const handleResendCode = async () => { + setLoading(true); + try { + const { error } = await supabase.functions.invoke('resend-deletion-code', { + body: {}, + }); + + if (error) throw error; + + toast({ + title: 'Code Resent', + description: 'A new confirmation code has been sent to your email.', + }); + } catch (error: any) { + toast({ + variant: 'destructive', + title: 'Error', + description: error.message || 'Failed to resend code', + }); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + setStep('warning'); + setConfirmationCode(''); + setCodeReceived(false); + onOpenChange(false); + }; + + return ( + + + + + + Delete Account + + + {step === 'warning' && ( + <> + + + + This action will permanently delete your account after a 2-week waiting period. Please read carefully. + + + +
+
+

What will be DELETED:

+
    +
  • ✗ Your profile information (username, bio, avatar, etc.)
  • +
  • ✗ Your reviews and ratings
  • +
  • ✗ Your personal preferences and settings
  • +
+
+ +
+

What will be PRESERVED:

+
    +
  • ✓ Your database submissions (park creations, ride additions, edits)
  • +
  • ✓ Photos you've uploaded (shown as "Submitted by [deleted user]")
  • +
  • ✓ Edit history and contributions
  • +
+
+ + + + 2-Week Waiting Period: Your account will be deactivated immediately, but you'll have 14 days to cancel before permanent deletion. You'll receive a confirmation code via email. + + +
+ + )} + + {step === 'confirm' && ( + + + Are you absolutely sure? This will deactivate your account immediately and schedule it for permanent deletion in 2 weeks. + + + )} + + {step === 'code' && ( +
+ + + + Your account has been deactivated and will be deleted on{' '} + {scheduledDate ? new Date(scheduledDate).toLocaleDateString() : '14 days from now'}. + + + +

+ A 6-digit confirmation code has been sent to {userEmail}. +

+ +
+
+ + setConfirmationCode(e.target.value.replace(/\D/g, ''))} + className="text-center text-2xl tracking-widest" + /> +
+ +
+ setCodeReceived(checked === true)} + /> + +
+ + +
+ + + + Note: You cannot confirm deletion until the 2-week period has passed. You can cancel at any time during this period. + + +
+ )} +
+
+ + {step === 'warning' && ( + <> + + + + )} + + {step === 'confirm' && ( + <> + + + + )} + + {step === 'code' && ( + <> + + + + )} + +
+
+ ); +} diff --git a/src/components/settings/AccountProfileTab.tsx b/src/components/settings/AccountProfileTab.tsx index ed836766..92c2ecee 100644 --- a/src/components/settings/AccountProfileTab.tsx +++ b/src/components/settings/AccountProfileTab.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; @@ -21,6 +21,8 @@ import { EmailChangeDialog } from './EmailChangeDialog'; import { Badge } from '@/components/ui/badge'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { toast as sonnerToast } from 'sonner'; +import { AccountDeletionDialog } from './AccountDeletionDialog'; +import { DeletionStatusBanner } from './DeletionStatusBanner'; const profileSchema = z.object({ username: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_-]+$/), @@ -44,6 +46,8 @@ export function AccountProfileTab() { const [cancellingEmail, setCancellingEmail] = useState(false); const [avatarUrl, setAvatarUrl] = useState(profile?.avatar_url || ''); const [avatarImageId, setAvatarImageId] = useState(profile?.avatar_image_id || ''); + const [showDeletionDialog, setShowDeletionDialog] = useState(false); + const [deletionRequest, setDeletionRequest] = useState(null); const form = useForm({ resolver: zodResolver(profileSchema), @@ -57,6 +61,26 @@ export function AccountProfileTab() { } }); + // Check for existing deletion request on mount + useEffect(() => { + const checkDeletionRequest = async () => { + if (!user?.id) return; + + const { data, error } = await supabase + .from('account_deletion_requests') + .select('*') + .eq('user_id', user.id) + .eq('status', 'pending') + .maybeSingle(); + + if (!error && data) { + setDeletionRequest(data); + } + }; + + checkDeletionRequest(); + }, [user?.id]); + const onSubmit = async (data: ProfileFormData) => { if (!user) return; @@ -200,30 +224,36 @@ export function AccountProfileTab() { } }; - const handleDeleteAccount = async () => { - if (!user) return; - - try { - // This would typically involve multiple steps: - // 1. Anonymize or delete user data - // 2. Delete the auth user - // For now, we'll just show a message - toast({ - title: 'Account deletion requested', - description: 'Please contact support to complete account deletion.', - variant: 'destructive' - }); - } catch (error: any) { - toast({ - title: 'Error', - description: error.message || 'Failed to delete account', - variant: 'destructive' - }); + const handleDeletionRequested = async () => { + // Refresh deletion request data + const { data, error } = await supabase + .from('account_deletion_requests') + .select('*') + .eq('user_id', user!.id) + .eq('status', 'pending') + .maybeSingle(); + + if (!error && data) { + setDeletionRequest(data); } }; + const handleDeletionCancelled = () => { + setDeletionRequest(null); + }; + + const isDeactivated = (profile as any)?.deactivated || false; + return (
+ {/* Deletion Status Banner */} + {deletionRequest && ( + + )} + {/* Profile Picture */}

Profile Picture

@@ -257,6 +287,7 @@ export function AccountProfileTab() { id="username" {...form.register('username')} placeholder="Enter your username" + disabled={isDeactivated} /> {form.formState.errors.username && (

@@ -325,9 +356,14 @@ export function AccountProfileTab() {

- + {isDeactivated && ( +

+ Your account is deactivated. Profile editing is disabled. +

+ )} @@ -440,39 +476,36 @@ export function AccountProfileTab() { Danger Zone - These actions cannot be undone. Please be careful. + Permanently delete your account with a 2-week waiting period - - - - - - - - Are you absolutely sure? - - This action cannot be undone. This will permanently delete your account - and remove all your data from our servers, including your reviews, - ride credits, and profile information. - - - - Cancel - - Yes, delete my account - - - - + +
+
+

Delete Account

+

+ Your profile and reviews will be deleted, but your contributions (submissions, photos) will be preserved +

+
+ +
+ + {/* Account Deletion Dialog */} + ); } \ No newline at end of file diff --git a/src/components/settings/DeletionStatusBanner.tsx b/src/components/settings/DeletionStatusBanner.tsx new file mode 100644 index 00000000..418aba8d --- /dev/null +++ b/src/components/settings/DeletionStatusBanner.tsx @@ -0,0 +1,90 @@ +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { supabase } from '@/integrations/supabase/client'; +import { useToast } from '@/hooks/use-toast'; +import { AlertTriangle, Loader2 } from 'lucide-react'; +import { useState } from 'react'; + +interface DeletionStatusBannerProps { + scheduledDate: string; + onCancelled: () => void; +} + +export function DeletionStatusBanner({ scheduledDate, onCancelled }: DeletionStatusBannerProps) { + const [loading, setLoading] = useState(false); + const { toast } = useToast(); + + const calculateDaysRemaining = () => { + const scheduled = new Date(scheduledDate); + const now = new Date(); + const diffTime = scheduled.getTime() - now.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + return Math.max(0, diffDays); + }; + + const handleCancelDeletion = async () => { + setLoading(true); + try { + const { error } = await supabase.functions.invoke('cancel-account-deletion', { + body: { cancellation_reason: 'User cancelled from settings' }, + }); + + if (error) throw error; + + toast({ + title: 'Deletion Cancelled', + description: 'Your account has been reactivated.', + }); + + onCancelled(); + } catch (error: any) { + toast({ + variant: 'destructive', + title: 'Error', + description: error.message || 'Failed to cancel deletion', + }); + } finally { + setLoading(false); + } + }; + + const daysRemaining = calculateDaysRemaining(); + const formattedDate = new Date(scheduledDate).toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + return ( + + + Account Deletion Scheduled + +

+ Your account is scheduled for permanent deletion on {formattedDate}. +

+

+ {daysRemaining > 0 ? ( + <> + {daysRemaining} day{daysRemaining !== 1 ? 's' : ''} remaining to cancel. + + ) : ( + 'You can now confirm deletion with your confirmation code.' + )} +

+
+ +
+
+
+ ); +} diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index d112510c..d368cc97 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -14,6 +14,51 @@ export type Database = { } public: { Tables: { + account_deletion_requests: { + Row: { + cancellation_reason: string | null + cancelled_at: string | null + completed_at: string | null + confirmation_code: string + confirmation_code_sent_at: string | null + created_at: string + id: string + requested_at: string + scheduled_deletion_at: string + status: Database["public"]["Enums"]["account_deletion_status"] + updated_at: string + user_id: string + } + Insert: { + cancellation_reason?: string | null + cancelled_at?: string | null + completed_at?: string | null + confirmation_code: string + confirmation_code_sent_at?: string | null + created_at?: string + id?: string + requested_at?: string + scheduled_deletion_at: string + status?: Database["public"]["Enums"]["account_deletion_status"] + updated_at?: string + user_id: string + } + Update: { + cancellation_reason?: string | null + cancelled_at?: string | null + completed_at?: string | null + confirmation_code?: string + confirmation_code_sent_at?: string | null + created_at?: string + id?: string + requested_at?: string + scheduled_deletion_at?: string + status?: Database["public"]["Enums"]["account_deletion_status"] + updated_at?: string + user_id?: string + } + Relationships: [] + } admin_audit_log: { Row: { action: string @@ -1348,6 +1393,9 @@ export type Database = { coaster_count: number | null created_at: string date_of_birth: string | null + deactivated: boolean + deactivated_at: string | null + deactivation_reason: string | null display_name: string | null home_park_id: string | null id: string @@ -1376,6 +1424,9 @@ export type Database = { coaster_count?: number | null created_at?: string date_of_birth?: string | null + deactivated?: boolean + deactivated_at?: string | null + deactivation_reason?: string | null display_name?: string | null home_park_id?: string | null id?: string @@ -1404,6 +1455,9 @@ export type Database = { coaster_count?: number | null created_at?: string date_of_birth?: string | null + deactivated?: boolean + deactivated_at?: string | null + deactivation_reason?: string | null display_name?: string | null home_park_id?: string | null id?: string @@ -2855,6 +2909,10 @@ export type Database = { } } Functions: { + anonymize_user_submissions: { + Args: { target_user_id: string } + Returns: undefined + } can_approve_submission_item: { Args: { item_id: string } Returns: boolean @@ -2931,6 +2989,10 @@ export type Database = { Args: { url: string } Returns: string } + generate_deletion_confirmation_code: { + Args: Record + Returns: string + } get_filtered_profile: { Args: { _profile_user_id: string; _viewer_id?: string } Returns: Json @@ -3039,6 +3101,11 @@ export type Database = { } } Enums: { + account_deletion_status: + | "pending" + | "confirmed" + | "cancelled" + | "completed" app_role: "admin" | "moderator" | "user" | "superuser" version_change_type: | "created" @@ -3173,6 +3240,12 @@ export type CompositeTypes< export const Constants = { public: { Enums: { + account_deletion_status: [ + "pending", + "confirmed", + "cancelled", + "completed", + ], app_role: ["admin", "moderator", "user", "superuser"], version_change_type: [ "created", diff --git a/supabase/functions/cancel-account-deletion/index.ts b/supabase/functions/cancel-account-deletion/index.ts new file mode 100644 index 00000000..22badb58 --- /dev/null +++ b/supabase/functions/cancel-account-deletion/index.ts @@ -0,0 +1,129 @@ +import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const { cancellation_reason } = await req.json(); + + const supabaseClient = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_ANON_KEY') ?? '', + { + global: { + headers: { Authorization: req.headers.get('Authorization')! }, + }, + } + ); + + // Get authenticated user + const { + data: { user }, + error: userError, + } = await supabaseClient.auth.getUser(); + + if (userError || !user) { + throw new Error('Unauthorized'); + } + + console.log(`Cancelling deletion request for user: ${user.id}`); + + // Find pending deletion request + const { data: deletionRequest, error: requestError } = await supabaseClient + .from('account_deletion_requests') + .select('*') + .eq('user_id', user.id) + .eq('status', 'pending') + .maybeSingle(); + + if (requestError || !deletionRequest) { + throw new Error('No pending deletion request found'); + } + + // Cancel deletion request + const { error: updateError } = await supabaseClient + .from('account_deletion_requests') + .update({ + status: 'cancelled', + cancelled_at: new Date().toISOString(), + cancellation_reason: cancellation_reason || 'User cancelled', + }) + .eq('id', deletionRequest.id); + + if (updateError) { + throw updateError; + } + + // Reactivate profile + const { error: profileError } = await supabaseClient + .from('profiles') + .update({ + deactivated: false, + deactivated_at: null, + deactivation_reason: null, + }) + .eq('user_id', user.id); + + if (profileError) { + throw profileError; + } + + // Send cancellation email + const forwardEmailKey = Deno.env.get('FORWARDEMAIL_API_KEY'); + const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS') || 'noreply@thrillwiki.com'; + + if (forwardEmailKey) { + try { + await fetch('https://api.forwardemail.net/v1/emails', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${btoa(forwardEmailKey + ':')}`, + }, + body: JSON.stringify({ + from: fromEmail, + to: user.email, + subject: 'Account Deletion Cancelled', + html: ` +

Account Deletion Cancelled

+

Your account deletion request has been cancelled on ${new Date().toLocaleDateString()}.

+

Your account has been reactivated and you can continue using all features.

+

Welcome back!

+ `, + }), + }); + console.log('Cancellation confirmation email sent'); + } catch (emailError) { + console.error('Failed to send email:', emailError); + } + } + + return new Response( + JSON.stringify({ + success: true, + message: 'Account deletion cancelled successfully', + }), + { + status: 200, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + } + ); + } catch (error) { + console.error('Error cancelling deletion:', error); + return new Response( + JSON.stringify({ error: error.message }), + { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + } + ); + } +}); diff --git a/supabase/functions/confirm-account-deletion/index.ts b/supabase/functions/confirm-account-deletion/index.ts new file mode 100644 index 00000000..facc7fe2 --- /dev/null +++ b/supabase/functions/confirm-account-deletion/index.ts @@ -0,0 +1,189 @@ +import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const { confirmation_code } = await req.json(); + + if (!confirmation_code) { + throw new Error('Confirmation code is required'); + } + + const supabaseClient = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_ANON_KEY') ?? '', + { + global: { + headers: { Authorization: req.headers.get('Authorization')! }, + }, + } + ); + + // Get authenticated user + const { + data: { user }, + error: userError, + } = await supabaseClient.auth.getUser(); + + if (userError || !user) { + throw new Error('Unauthorized'); + } + + console.log(`Confirming deletion for user: ${user.id}`); + + // Find deletion request + const { data: deletionRequest, error: requestError } = await supabaseClient + .from('account_deletion_requests') + .select('*') + .eq('user_id', user.id) + .eq('status', 'pending') + .maybeSingle(); + + if (requestError || !deletionRequest) { + throw new Error('No pending deletion request found'); + } + + // Verify confirmation code + if (deletionRequest.confirmation_code !== confirmation_code) { + throw new Error('Invalid confirmation code'); + } + + // Check if 14 days have passed + const scheduledDate = new Date(deletionRequest.scheduled_deletion_at); + const now = new Date(); + + if (now < scheduledDate) { + const daysRemaining = Math.ceil((scheduledDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + throw new Error(`You must wait ${daysRemaining} more day(s) before confirming deletion`); + } + + // Use service role client for admin operations + const supabaseAdmin = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '' + ); + + console.log('Starting deletion process...'); + + // Delete reviews (CASCADE will handle review_photos) + const { error: reviewsError } = await supabaseAdmin + .from('reviews') + .delete() + .eq('user_id', user.id); + + if (reviewsError) { + console.error('Error deleting reviews:', reviewsError); + } + + // Anonymize submissions and photos + const { error: anonymizeError } = await supabaseAdmin + .rpc('anonymize_user_submissions', { target_user_id: user.id }); + + if (anonymizeError) { + console.error('Error anonymizing submissions:', anonymizeError); + } + + // Delete user roles + const { error: rolesError } = await supabaseAdmin + .from('user_roles') + .delete() + .eq('user_id', user.id); + + if (rolesError) { + console.error('Error deleting user roles:', rolesError); + } + + // Delete profile + const { error: profileError } = await supabaseAdmin + .from('profiles') + .delete() + .eq('user_id', user.id); + + if (profileError) { + console.error('Error deleting profile:', profileError); + throw profileError; + } + + // Update deletion request status + const { error: updateError } = await supabaseAdmin + .from('account_deletion_requests') + .update({ + status: 'completed', + completed_at: new Date().toISOString(), + }) + .eq('id', deletionRequest.id); + + if (updateError) { + console.error('Error updating deletion request:', updateError); + } + + // Delete auth user (this should cascade delete the deletion request) + const { error: authError } = await supabaseAdmin.auth.admin.deleteUser(user.id); + + if (authError) { + console.error('Error deleting auth user:', authError); + throw authError; + } + + // Send confirmation email + const forwardEmailKey = Deno.env.get('FORWARDEMAIL_API_KEY'); + const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS') || 'noreply@thrillwiki.com'; + + if (forwardEmailKey) { + try { + await fetch('https://api.forwardemail.net/v1/emails', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${btoa(forwardEmailKey + ':')}`, + }, + body: JSON.stringify({ + from: fromEmail, + to: user.email, + subject: 'Account Deletion Confirmed', + html: ` +

Account Deletion Confirmed

+

Your account has been permanently deleted on ${new Date().toLocaleDateString()}.

+

Your profile and reviews have been removed, but your contributions to the database remain preserved.

+

Thank you for being part of our community.

+ `, + }), + }); + console.log('Deletion confirmation email sent'); + } catch (emailError) { + console.error('Failed to send email:', emailError); + } + } + + console.log('Account deletion completed successfully'); + + return new Response( + JSON.stringify({ + success: true, + message: 'Account deleted successfully', + }), + { + status: 200, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + } + ); + } catch (error) { + console.error('Error confirming deletion:', error); + return new Response( + JSON.stringify({ error: error.message }), + { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + } + ); + } +}); diff --git a/supabase/functions/process-scheduled-deletions/index.ts b/supabase/functions/process-scheduled-deletions/index.ts new file mode 100644 index 00000000..8f1f3531 --- /dev/null +++ b/supabase/functions/process-scheduled-deletions/index.ts @@ -0,0 +1,159 @@ +import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + // Use service role for admin operations + const supabaseAdmin = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '' + ); + + console.log('Processing scheduled account deletions...'); + + // Find pending deletion requests that are past their scheduled date + const { data: pendingDeletions, error: fetchError } = await supabaseAdmin + .from('account_deletion_requests') + .select('*') + .eq('status', 'pending') + .lt('scheduled_deletion_at', new Date().toISOString()); + + if (fetchError) { + throw fetchError; + } + + if (!pendingDeletions || pendingDeletions.length === 0) { + console.log('No deletions to process'); + return new Response( + JSON.stringify({ + success: true, + message: 'No deletions to process', + processed: 0, + }), + { + status: 200, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + } + ); + } + + console.log(`Found ${pendingDeletions.length} deletion(s) to process`); + + let successCount = 0; + let errorCount = 0; + + for (const deletion of pendingDeletions) { + try { + console.log(`Processing deletion for user: ${deletion.user_id}`); + + // Get user email for confirmation email + const { data: userData } = await supabaseAdmin.auth.admin.getUserById(deletion.user_id); + const userEmail = userData?.user?.email; + + // Delete reviews (CASCADE will handle review_photos) + await supabaseAdmin + .from('reviews') + .delete() + .eq('user_id', deletion.user_id); + + // Anonymize submissions and photos + await supabaseAdmin + .rpc('anonymize_user_submissions', { target_user_id: deletion.user_id }); + + // Delete user roles + await supabaseAdmin + .from('user_roles') + .delete() + .eq('user_id', deletion.user_id); + + // Delete profile + await supabaseAdmin + .from('profiles') + .delete() + .eq('user_id', deletion.user_id); + + // Update deletion request status + await supabaseAdmin + .from('account_deletion_requests') + .update({ + status: 'completed', + completed_at: new Date().toISOString(), + }) + .eq('id', deletion.id); + + // Delete auth user + await supabaseAdmin.auth.admin.deleteUser(deletion.user_id); + + // Send final confirmation email if we have the email + if (userEmail) { + const forwardEmailKey = Deno.env.get('FORWARDEMAIL_API_KEY'); + const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS') || 'noreply@thrillwiki.com'; + + if (forwardEmailKey) { + try { + await fetch('https://api.forwardemail.net/v1/emails', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${btoa(forwardEmailKey + ':')}`, + }, + body: JSON.stringify({ + from: fromEmail, + to: userEmail, + subject: 'Account Deletion Completed', + html: ` +

Account Deletion Completed

+

Your account has been automatically deleted as scheduled on ${new Date().toLocaleDateString()}.

+

Your profile and reviews have been removed, but your contributions to the database remain preserved.

+

Thank you for being part of our community.

+ `, + }), + }); + } catch (emailError) { + console.error('Failed to send confirmation email:', emailError); + } + } + } + + successCount++; + console.log(`Successfully deleted account for user: ${deletion.user_id}`); + } catch (error) { + errorCount++; + console.error(`Failed to delete account for user ${deletion.user_id}:`, error); + } + } + + console.log(`Processed ${successCount} deletion(s) successfully, ${errorCount} error(s)`); + + return new Response( + JSON.stringify({ + success: true, + message: `Processed ${successCount} deletion(s)`, + processed: successCount, + errors: errorCount, + }), + { + status: 200, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + } + ); + } catch (error) { + console.error('Error processing scheduled deletions:', error); + return new Response( + JSON.stringify({ error: error.message }), + { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + } + ); + } +}); diff --git a/supabase/functions/request-account-deletion/index.ts b/supabase/functions/request-account-deletion/index.ts new file mode 100644 index 00000000..52ec11e8 --- /dev/null +++ b/supabase/functions/request-account-deletion/index.ts @@ -0,0 +1,181 @@ +import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const supabaseClient = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_ANON_KEY') ?? '', + { + global: { + headers: { Authorization: req.headers.get('Authorization')! }, + }, + } + ); + + // Get authenticated user + const { + data: { user }, + error: userError, + } = await supabaseClient.auth.getUser(); + + if (userError || !user) { + throw new Error('Unauthorized'); + } + + console.log(`Processing deletion request for user: ${user.id}`); + + // Check for existing pending deletion request + const { data: existingRequest } = await supabaseClient + .from('account_deletion_requests') + .select('*') + .eq('user_id', user.id) + .eq('status', 'pending') + .maybeSingle(); + + if (existingRequest) { + return new Response( + JSON.stringify({ + error: 'You already have a pending deletion request', + request: existingRequest, + }), + { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + } + ); + } + + // Generate confirmation code + const { data: codeData, error: codeError } = await supabaseClient + .rpc('generate_deletion_confirmation_code'); + + if (codeError) { + throw codeError; + } + + const confirmationCode = codeData as string; + const scheduledDeletionAt = new Date(); + scheduledDeletionAt.setDate(scheduledDeletionAt.getDate() + 14); + + // Create deletion request + const { data: deletionRequest, error: requestError } = await supabaseClient + .from('account_deletion_requests') + .insert({ + user_id: user.id, + confirmation_code: confirmationCode, + confirmation_code_sent_at: new Date().toISOString(), + scheduled_deletion_at: scheduledDeletionAt.toISOString(), + status: 'pending', + }) + .select() + .single(); + + if (requestError) { + throw requestError; + } + + // Deactivate profile + const { error: profileError } = await supabaseClient + .from('profiles') + .update({ + deactivated: true, + deactivated_at: new Date().toISOString(), + deactivation_reason: 'User requested account deletion', + }) + .eq('user_id', user.id); + + if (profileError) { + throw profileError; + } + + // Send confirmation email + const emailPayload = { + to: user.email, + subject: 'Account Deletion Requested - Confirmation Code Inside', + html: ` +

Account Deletion Requested

+

Hello,

+

We received a request to delete your account on ${new Date().toLocaleDateString()}.

+ +

IMPORTANT INFORMATION:

+

Your account has been deactivated and will be permanently deleted on ${scheduledDeletionAt.toLocaleDateString()} (14 days from now).

+ +

What will be DELETED:

+
    +
  • ✗ Your profile information (username, bio, avatar, etc.)
  • +
  • ✗ Your reviews and ratings
  • +
  • ✗ Your personal preferences and settings
  • +
+ +

What will be PRESERVED:

+
    +
  • ✓ Your database submissions (park creations, ride additions, edits)
  • +
  • ✓ Photos you've uploaded (will be shown as "Submitted by [deleted user]")
  • +
  • ✓ Edit history and contributions
  • +
+ +

CONFIRMATION CODE: ${confirmationCode}

+

To confirm deletion after the 14-day period, you'll need to enter this 6-digit code.

+ +

Need to cancel? Log in and visit your account settings to reactivate your account at any time during the next 14 days.

+ `, + }; + + // Send via ForwardEmail API + const forwardEmailKey = Deno.env.get('FORWARDEMAIL_API_KEY'); + const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS') || 'noreply@thrillwiki.com'; + + if (forwardEmailKey) { + try { + await fetch('https://api.forwardemail.net/v1/emails', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${btoa(forwardEmailKey + ':')}`, + }, + body: JSON.stringify({ + from: fromEmail, + to: emailPayload.to, + subject: emailPayload.subject, + html: emailPayload.html, + }), + }); + console.log('Deletion confirmation email sent'); + } catch (emailError) { + console.error('Failed to send email:', emailError); + } + } + + return new Response( + JSON.stringify({ + success: true, + message: 'Account deletion request created successfully', + scheduled_deletion_at: scheduledDeletionAt.toISOString(), + request_id: deletionRequest.id, + }), + { + status: 200, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + } + ); + } catch (error) { + console.error('Error processing deletion request:', error); + return new Response( + JSON.stringify({ error: error.message }), + { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + } + ); + } +}); diff --git a/supabase/functions/resend-deletion-code/index.ts b/supabase/functions/resend-deletion-code/index.ts new file mode 100644 index 00000000..3ebcf740 --- /dev/null +++ b/supabase/functions/resend-deletion-code/index.ts @@ -0,0 +1,137 @@ +import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const supabaseClient = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_ANON_KEY') ?? '', + { + global: { + headers: { Authorization: req.headers.get('Authorization')! }, + }, + } + ); + + // Get authenticated user + const { + data: { user }, + error: userError, + } = await supabaseClient.auth.getUser(); + + if (userError || !user) { + throw new Error('Unauthorized'); + } + + console.log(`Resending deletion code for user: ${user.id}`); + + // Find pending deletion request + const { data: deletionRequest, error: requestError } = await supabaseClient + .from('account_deletion_requests') + .select('*') + .eq('user_id', user.id) + .eq('status', 'pending') + .maybeSingle(); + + if (requestError || !deletionRequest) { + throw new Error('No pending deletion request found'); + } + + // Check rate limiting (max 3 resends per hour) + const lastSent = new Date(deletionRequest.confirmation_code_sent_at); + const now = new Date(); + const hoursSinceLastSend = (now.getTime() - lastSent.getTime()) / (1000 * 60 * 60); + + if (hoursSinceLastSend < 0.33) { // ~20 minutes between resends + throw new Error('Please wait at least 20 minutes between resend requests'); + } + + // Generate new confirmation code + const { data: codeData, error: codeError } = await supabaseClient + .rpc('generate_deletion_confirmation_code'); + + if (codeError) { + throw codeError; + } + + const confirmationCode = codeData as string; + + // Update deletion request with new code + const { error: updateError } = await supabaseClient + .from('account_deletion_requests') + .update({ + confirmation_code: confirmationCode, + confirmation_code_sent_at: now.toISOString(), + }) + .eq('id', deletionRequest.id); + + if (updateError) { + throw updateError; + } + + const scheduledDate = new Date(deletionRequest.scheduled_deletion_at); + + // Send email with new code + const forwardEmailKey = Deno.env.get('FORWARDEMAIL_API_KEY'); + const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS') || 'noreply@thrillwiki.com'; + + if (forwardEmailKey) { + try { + await fetch('https://api.forwardemail.net/v1/emails', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${btoa(forwardEmailKey + ':')}`, + }, + body: JSON.stringify({ + from: fromEmail, + to: user.email, + subject: 'Account Deletion - New Confirmation Code', + html: ` +

New Confirmation Code

+

You requested a new confirmation code for your account deletion.

+

Your account will be permanently deleted on ${scheduledDate.toLocaleDateString()}.

+ +

CONFIRMATION CODE: ${confirmationCode}

+

To confirm deletion after the waiting period, you'll need to enter this 6-digit code.

+ +

Need to cancel? Log in and visit your account settings to reactivate your account.

+ `, + }), + }); + console.log('New confirmation code email sent'); + } catch (emailError) { + console.error('Failed to send email:', emailError); + } + } + + return new Response( + JSON.stringify({ + success: true, + message: 'New confirmation code sent successfully', + }), + { + status: 200, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + } + ); + } catch (error) { + console.error('Error resending code:', error); + return new Response( + JSON.stringify({ error: error.message }), + { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + } + ); + } +}); diff --git a/supabase/migrations/20251012141402_a98e8fc9-ceb3-4a78-87e2-8246b4e63b17.sql b/supabase/migrations/20251012141402_a98e8fc9-ceb3-4a78-87e2-8246b4e63b17.sql new file mode 100644 index 00000000..c39695eb --- /dev/null +++ b/supabase/migrations/20251012141402_a98e8fc9-ceb3-4a78-87e2-8246b4e63b17.sql @@ -0,0 +1,106 @@ +-- Create account deletion status enum +CREATE TYPE account_deletion_status AS ENUM ('pending', 'confirmed', 'cancelled', 'completed'); + +-- Create account deletion requests table +CREATE TABLE account_deletion_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + requested_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + scheduled_deletion_at TIMESTAMP WITH TIME ZONE NOT NULL, + confirmation_code TEXT NOT NULL, + confirmation_code_sent_at TIMESTAMP WITH TIME ZONE, + status account_deletion_status NOT NULL DEFAULT 'pending', + cancelled_at TIMESTAMP WITH TIME ZONE, + completed_at TIMESTAMP WITH TIME ZONE, + cancellation_reason TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Add unique constraint for active deletions +CREATE UNIQUE INDEX unique_active_deletion_per_user +ON account_deletion_requests(user_id) +WHERE status = 'pending'; + +-- Add index for scheduled deletions +CREATE INDEX idx_account_deletions_scheduled +ON account_deletion_requests(scheduled_deletion_at) +WHERE status = 'pending'; + +-- Enable RLS +ALTER TABLE account_deletion_requests ENABLE ROW LEVEL SECURITY; + +-- RLS Policies +CREATE POLICY "Users can view their own deletion requests" + ON account_deletion_requests FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert their own deletion requests" + ON account_deletion_requests FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update their own deletion requests" + ON account_deletion_requests FOR UPDATE + USING (auth.uid() = user_id); + +CREATE POLICY "Admins can view all deletion requests" + ON account_deletion_requests FOR SELECT + USING (is_moderator(auth.uid())); + +-- Add deactivation columns to profiles +ALTER TABLE profiles +ADD COLUMN deactivated BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN deactivated_at TIMESTAMP WITH TIME ZONE, +ADD COLUMN deactivation_reason TEXT; + +-- Index for deactivated profiles +CREATE INDEX idx_profiles_deactivated ON profiles(deactivated) WHERE deactivated = true; + +-- Update profile RLS to hide deactivated profiles +CREATE POLICY "Hide deactivated profiles from public" + ON profiles FOR SELECT + USING ( + NOT deactivated OR auth.uid() = user_id OR is_moderator(auth.uid()) + ); + +-- Helper function to generate confirmation code +CREATE OR REPLACE FUNCTION generate_deletion_confirmation_code() +RETURNS TEXT +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + code TEXT; +BEGIN + code := LPAD(FLOOR(RANDOM() * 1000000)::TEXT, 6, '0'); + RETURN code; +END; +$$; + +-- Helper function to anonymize user data +CREATE OR REPLACE FUNCTION anonymize_user_submissions(target_user_id UUID) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + -- Nullify user_id in content_submissions to preserve submissions + UPDATE content_submissions + SET user_id = NULL + WHERE user_id = target_user_id; + + -- Nullify submitted_by in photos to preserve photos + UPDATE photos + SET submitted_by = NULL, + photographer_credit = '[Deleted User]' + WHERE submitted_by = target_user_id; +END; +$$; + +-- Add trigger for updated_at on account_deletion_requests +CREATE TRIGGER update_account_deletion_requests_updated_at + BEFORE UPDATE ON account_deletion_requests + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file