diff --git a/src/components/settings/AccountProfileTab.tsx b/src/components/settings/AccountProfileTab.tsx index ef01e984..f11ffd35 100644 --- a/src/components/settings/AccountProfileTab.tsx +++ b/src/components/settings/AccountProfileTab.tsx @@ -23,16 +23,18 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { toast as sonnerToast } from 'sonner'; import { AccountDeletionDialog } from './AccountDeletionDialog'; import { DeletionStatusBanner } from './DeletionStatusBanner'; -import { usernameSchema, displayNameSchema, bioSchema } from '@/lib/validation'; +import { usernameSchema, displayNameSchema, bioSchema, personalLocationSchema, preferredPronounsSchema } from '@/lib/validation'; import { z } from 'zod'; +import { AccountDeletionRequest } from '@/types/database'; const profileSchema = z.object({ username: usernameSchema, display_name: displayNameSchema, bio: bioSchema, - preferred_pronouns: z.string().max(20).optional(), + preferred_pronouns: preferredPronounsSchema, show_pronouns: z.boolean(), - preferred_language: z.string() + preferred_language: z.string(), + personal_location: personalLocationSchema }); type ProfileFormData = z.infer; @@ -50,7 +52,7 @@ export function AccountProfileTab() { 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 [deletionRequest, setDeletionRequest] = useState(null); const form = useForm({ resolver: zodResolver(profileSchema), @@ -60,7 +62,8 @@ export function AccountProfileTab() { bio: profile?.bio || '', preferred_pronouns: profile?.preferred_pronouns || '', show_pronouns: profile?.show_pronouns || false, - preferred_language: profile?.preferred_language || 'en' + preferred_language: profile?.preferred_language || 'en', + personal_location: profile?.personal_location || '' } }); @@ -89,29 +92,28 @@ export function AccountProfileTab() { setLoading(true); try { - const usernameChanged = profile?.username !== data.username; - - const { error } = await supabase - .from('profiles') - .update({ - username: data.username, - display_name: data.display_name || null, - bio: data.bio || null, - preferred_pronouns: data.preferred_pronouns || null, - show_pronouns: data.show_pronouns, - preferred_language: data.preferred_language, - updated_at: new Date().toISOString() - }) - .eq('user_id', user.id); + // Use the update_profile RPC function with server-side validation + const { data: result, error } = await supabase.rpc('update_profile', { + p_username: data.username, + p_display_name: data.display_name || null, + p_bio: data.bio || null, + p_preferred_pronouns: data.preferred_pronouns || null, + p_show_pronouns: data.show_pronouns, + p_preferred_language: data.preferred_language, + p_personal_location: data.personal_location || null + }); if (error) throw error; + // Type the RPC result + const rpcResult = result as unknown as { success: boolean; username_changed: boolean; changes_count: number }; + // Update Novu subscriber if username changed - if (usernameChanged && notificationService.isEnabled()) { + if (rpcResult?.username_changed && notificationService.isEnabled()) { await notificationService.updateSubscriber({ subscriberId: user.id, email: user.email, - firstName: data.username, // Send username as firstName to Novu + firstName: data.username, }); } @@ -137,19 +139,17 @@ export function AccountProfileTab() { const newAvatarUrl = urls[0]; const newImageId = imageId || ''; - // Update local state immediately + // Update local state immediately for optimistic UI setAvatarUrl(newAvatarUrl); setAvatarImageId(newImageId); try { - const { error } = await supabase - .from('profiles') - .update({ - avatar_url: newAvatarUrl, - avatar_image_id: newImageId, - updated_at: new Date().toISOString() - }) - .eq('user_id', user.id); + // Use update_profile RPC for avatar updates + const { error } = await supabase.rpc('update_profile', { + p_username: profile?.username || '', + p_avatar_url: newAvatarUrl, + p_avatar_image_id: newImageId + }); if (error) throw error; @@ -245,7 +245,7 @@ export function AccountProfileTab() { setDeletionRequest(null); }; - const isDeactivated = (profile as any)?.deactivated || false; + const isDeactivated = profile?.deactivated || false; return (
@@ -337,6 +337,11 @@ export function AccountProfileTab() { {...form.register('preferred_pronouns')} placeholder="e.g., they/them, she/her, he/him" /> + {form.formState.errors.preferred_pronouns && ( +

+ {form.formState.errors.preferred_pronouns.message} +

+ )}
@@ -359,6 +364,20 @@ export function AccountProfileTab() {
+
+ + + {form.formState.errors.personal_location && ( +

+ {form.formState.errors.personal_location.message} +

+ )} +
+ diff --git a/src/hooks/useProfile.tsx b/src/hooks/useProfile.tsx index 027f413e..419268d5 100644 --- a/src/hooks/useProfile.tsx +++ b/src/hooks/useProfile.tsx @@ -29,7 +29,7 @@ export function useProfile(userId: string | undefined) { if (!data) return null; - // Type assertion since we know the structure from the RPC function + // Type the JSONB response properly const profileData = data as unknown as Profile; // Fetch location separately if location_id is present and visible diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index d7c4d710..d618aa59 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -3080,6 +3080,20 @@ export type Database = { Args: { target_park_id: string } Returns: undefined } + update_profile: { + Args: { + p_avatar_image_id?: string + p_avatar_url?: string + p_bio?: string + p_display_name?: string + p_personal_location?: string + p_preferred_language?: string + p_preferred_pronouns?: string + p_show_pronouns?: boolean + p_username: string + } + Returns: Json + } update_ride_ratings: { Args: { target_ride_id: string } Returns: undefined diff --git a/src/lib/emailValidation.ts b/src/lib/emailValidation.ts index 22501cdf..80511d36 100644 --- a/src/lib/emailValidation.ts +++ b/src/lib/emailValidation.ts @@ -8,11 +8,11 @@ interface EmailValidationResult { /** * Validates an email address against disposable email domains - * Uses the validate-email edge function to check the backend blocklist + * Uses the validate-email-backend edge function for server-side validation */ export async function validateEmailNotDisposable(email: string): Promise { try { - const { data, error } = await supabase.functions.invoke('validate-email', { + const { data, error } = await supabase.functions.invoke('validate-email-backend', { body: { email } }); diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 32e1df79..c0aa2dd8 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -92,6 +92,13 @@ export const personalLocationSchema = z.string() ) .optional(); +// Preferred pronouns validation +export const preferredPronounsSchema = z + .string() + .trim() + .max(20, { message: "Pronouns must be less than 20 characters" }) + .optional(); + export const profileEditSchema = z.object({ username: usernameSchema, display_name: displayNameSchema, diff --git a/src/types/database.ts b/src/types/database.ts index 909a849d..713b18cb 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -186,6 +186,26 @@ export interface Profile { park_count: number; review_count: number; reputation_score: number; + banned: boolean; + deactivated: boolean; + deactivated_at?: string; + deactivation_reason?: string; + oauth_provider?: string; + created_at: string; + updated_at: string; +} + +export interface AccountDeletionRequest { + id: string; + user_id: string; + requested_at: string; + scheduled_deletion_at: string; + confirmation_code: string; + confirmation_code_sent_at?: string; + status: 'pending' | 'confirmed' | 'cancelled' | 'completed'; + cancelled_at?: string; + completed_at?: string; + cancellation_reason?: string; created_at: string; updated_at: string; } diff --git a/supabase/config.toml b/supabase/config.toml index 4998c2ef..a8063642 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -40,4 +40,7 @@ verify_jwt = true verify_jwt = true [functions.process-oauth-profile] -verify_jwt = true \ No newline at end of file +verify_jwt = true + +[functions.validate-email-backend] +verify_jwt = false \ No newline at end of file diff --git a/supabase/functions/validate-email-backend/index.ts b/supabase/functions/validate-email-backend/index.ts new file mode 100644 index 00000000..486c2e90 --- /dev/null +++ b/supabase/functions/validate-email-backend/index.ts @@ -0,0 +1,94 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.3'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +// Common disposable email domains (subset for performance) +const DISPOSABLE_DOMAINS = new Set([ + 'tempmail.com', 'guerrillamail.com', '10minutemail.com', 'mailinator.com', + 'throwaway.email', 'temp-mail.org', 'fakeinbox.com', 'maildrop.cc', + 'yopmail.com', 'sharklasers.com', 'guerrillamailblock.com' +]); + +interface EmailValidationResult { + valid: boolean; + reason?: string; + suggestions?: string[]; +} + +function validateEmailFormat(email: string): EmailValidationResult { + // Basic format validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return { valid: false, reason: 'Invalid email format' }; + } + + // Extract domain + const domain = email.split('@')[1].toLowerCase(); + + // Check against disposable domains + if (DISPOSABLE_DOMAINS.has(domain)) { + return { + valid: false, + reason: 'Disposable email addresses are not allowed. Please use a permanent email address.', + suggestions: ['gmail.com', 'outlook.com', 'yahoo.com', 'protonmail.com'] + }; + } + + // Check for suspicious patterns + if (domain.includes('temp') || domain.includes('disposable') || domain.includes('trash')) { + return { + valid: false, + reason: 'This email domain appears to be temporary. Please use a permanent email address.', + }; + } + + return { valid: true }; +} + +serve(async (req) => { + // Handle CORS preflight requests + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const { email } = await req.json(); + + if (!email || typeof email !== 'string') { + return new Response( + JSON.stringify({ valid: false, reason: 'Email is required' }), + { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + } + ); + } + + // Validate email + const result = validateEmailFormat(email.toLowerCase().trim()); + + return new Response( + JSON.stringify(result), + { + status: 200, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + } + ); + } catch (error) { + console.error('Email validation error:', error); + return new Response( + JSON.stringify({ + valid: false, + reason: 'Failed to validate email. Please try again.' + }), + { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + } + ); + } +}); diff --git a/supabase/migrations/20251014184157_f1afbcc2-f515-4f39-a899-c753797027ea.sql b/supabase/migrations/20251014184157_f1afbcc2-f515-4f39-a899-c753797027ea.sql new file mode 100644 index 00000000..708dd36b --- /dev/null +++ b/supabase/migrations/20251014184157_f1afbcc2-f515-4f39-a899-c753797027ea.sql @@ -0,0 +1,126 @@ +-- Phase 2: Create update_profile RPC function with validation and audit logging +CREATE OR REPLACE FUNCTION public.update_profile( + p_username text, + p_display_name text DEFAULT NULL, + p_bio text DEFAULT NULL, + p_preferred_pronouns text DEFAULT NULL, + p_show_pronouns boolean DEFAULT false, + p_preferred_language text DEFAULT 'en', + p_personal_location text DEFAULT NULL, + p_avatar_url text DEFAULT NULL, + p_avatar_image_id text DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_user_id uuid; + v_old_profile record; + v_changes jsonb := '[]'::jsonb; + v_change jsonb; +BEGIN + -- Get authenticated user ID + v_user_id := auth.uid(); + + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + -- Get current profile for comparison + SELECT * INTO v_old_profile + FROM public.profiles + WHERE user_id = v_user_id; + + IF v_old_profile IS NULL THEN + RAISE EXCEPTION 'Profile not found'; + END IF; + + -- Check if account is deactivated + IF v_old_profile.deactivated THEN + RAISE EXCEPTION 'Cannot update deactivated account'; + END IF; + + -- Validate username length and format (3-20 chars, alphanumeric, underscore, hyphen) + IF p_username IS NOT NULL THEN + IF LENGTH(p_username) < 3 OR LENGTH(p_username) > 20 THEN + RAISE EXCEPTION 'Username must be between 3 and 20 characters'; + END IF; + IF p_username !~ '^[a-z0-9_-]+$' THEN + RAISE EXCEPTION 'Username can only contain lowercase letters, numbers, underscores, and hyphens'; + END IF; + -- Check for consecutive special characters + IF p_username ~ '__' OR p_username ~ '--' OR p_username ~ '_-' OR p_username ~ '-_' THEN + RAISE EXCEPTION 'Username cannot contain consecutive special characters'; + END IF; + END IF; + + -- Validate display_name length (max 50 chars) + IF p_display_name IS NOT NULL AND LENGTH(p_display_name) > 50 THEN + RAISE EXCEPTION 'Display name must be 50 characters or less'; + END IF; + + -- Validate bio length (max 500 chars) + IF p_bio IS NOT NULL AND LENGTH(p_bio) > 500 THEN + RAISE EXCEPTION 'Bio must be 500 characters or less'; + END IF; + + -- Validate personal_location (max 100 chars, no special chars like <>) + IF p_personal_location IS NOT NULL THEN + IF LENGTH(p_personal_location) > 100 THEN + RAISE EXCEPTION 'Personal location must be 100 characters or less'; + END IF; + IF p_personal_location ~ '[<>]' THEN + RAISE EXCEPTION 'Personal location contains invalid characters'; + END IF; + END IF; + + -- Track changes for audit log + IF v_old_profile.username IS DISTINCT FROM p_username THEN + v_changes := v_changes || jsonb_build_array( + jsonb_build_object('field', 'username', 'old', v_old_profile.username, 'new', p_username) + ); + END IF; + + IF v_old_profile.display_name IS DISTINCT FROM p_display_name THEN + v_changes := v_changes || jsonb_build_array( + jsonb_build_object('field', 'display_name', 'old', v_old_profile.display_name, 'new', p_display_name) + ); + END IF; + + IF v_old_profile.bio IS DISTINCT FROM p_bio THEN + v_changes := v_changes || jsonb_build_array( + jsonb_build_object('field', 'bio', 'old', v_old_profile.bio, 'new', p_bio) + ); + END IF; + + -- Update profile (updated_at is handled by trigger) + UPDATE public.profiles + SET + username = COALESCE(p_username, username), + display_name = p_display_name, + bio = p_bio, + preferred_pronouns = p_preferred_pronouns, + show_pronouns = p_show_pronouns, + preferred_language = COALESCE(p_preferred_language, preferred_language), + personal_location = p_personal_location, + avatar_url = COALESCE(p_avatar_url, avatar_url), + avatar_image_id = COALESCE(p_avatar_image_id, avatar_image_id) + WHERE user_id = v_user_id; + + -- Log significant changes + IF jsonb_array_length(v_changes) > 0 THEN + INSERT INTO public.admin_audit_log (admin_user_id, target_user_id, action, details) + VALUES (v_user_id, v_user_id, 'profile_updated', jsonb_build_object('changes', v_changes)); + END IF; + + RETURN jsonb_build_object( + 'success', true, + 'username_changed', v_old_profile.username IS DISTINCT FROM p_username, + 'changes_count', jsonb_array_length(v_changes) + ); +END; +$$; + +GRANT EXECUTE ON FUNCTION public.update_profile TO authenticated; \ No newline at end of file