diff --git a/src/components/privacy/BlockedUsers.tsx b/src/components/privacy/BlockedUsers.tsx index 37b8da5b..04f8bfe5 100644 --- a/src/components/privacy/BlockedUsers.tsx +++ b/src/components/privacy/BlockedUsers.tsx @@ -1,29 +1,17 @@ import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'; import { UserX, Trash2 } from 'lucide-react'; import { supabase } from '@/integrations/supabase/client'; import { useAuth } from '@/hooks/useAuth'; -import { useToast } from '@/hooks/use-toast'; - -interface BlockedUser { - id: string; - blocked_id: string; - reason?: string; - created_at: string; - blocked_profile?: { - username: string; - display_name?: string; - avatar_url?: string; - }; -} +import { handleError, handleSuccess } from '@/lib/errorHandler'; +import { logger } from '@/lib/logger'; +import type { UserBlock } from '@/types/privacy'; export function BlockedUsers() { const { user } = useAuth(); - const { toast } = useToast(); - const [blockedUsers, setBlockedUsers] = useState([]); + const [blockedUsers, setBlockedUsers] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { @@ -43,7 +31,15 @@ export function BlockedUsers() { .eq('blocker_id', user.id) .order('created_at', { ascending: false }); - if (blocksError) throw blocksError; + if (blocksError) { + logger.error('Failed to fetch user blocks', { + userId: user.id, + action: 'fetch_blocked_users', + error: blocksError.message, + errorCode: blocksError.code + }); + throw blocksError; + } if (!blocks || blocks.length === 0) { setBlockedUsers([]); @@ -57,46 +53,99 @@ export function BlockedUsers() { .select('user_id, username, display_name, avatar_url') .in('user_id', blockedIds); - if (profilesError) throw profilesError; + if (profilesError) { + logger.error('Failed to fetch blocked user profiles', { + userId: user.id, + action: 'fetch_blocked_user_profiles', + error: profilesError.message, + errorCode: profilesError.code + }); + throw profilesError; + } // Combine the data const blockedUsersWithProfiles = blocks.map(block => ({ ...block, + blocker_id: user.id, blocked_profile: profiles?.find(p => p.user_id === block.blocked_id) })); setBlockedUsers(blockedUsersWithProfiles); - } catch (error: any) { - console.error('Error fetching blocked users:', error); - toast({ - title: 'Error', - description: 'Failed to load blocked users', - variant: 'destructive' + + logger.info('Blocked users fetched successfully', { + userId: user.id, + action: 'fetch_blocked_users', + count: blockedUsersWithProfiles.length + }); + } catch (error) { + logger.error('Error fetching blocked users', { + userId: user.id, + action: 'fetch_blocked_users', + error: error instanceof Error ? error.message : String(error) + }); + + handleError(error, { + action: 'Load blocked users', + userId: user.id }); } finally { setLoading(false); } }; - const handleUnblock = async (blockId: string, username: string) => { + const handleUnblock = async (blockId: string, blockedUserId: string, username: string) => { + if (!user) return; + try { const { error } = await supabase .from('user_blocks') .delete() .eq('id', blockId); - if (error) throw error; + if (error) { + logger.error('Failed to unblock user', { + userId: user.id, + action: 'unblock_user', + targetUserId: blockedUserId, + error: error.message, + errorCode: error.code + }); + throw error; + } + + // Log to audit trail + await supabase.from('profile_audit_log').insert({ + user_id: user.id, + changed_by: user.id, + action: 'user_unblocked', + changes: { + blocked_user_id: blockedUserId, + username, + timestamp: new Date().toISOString() + } + }); setBlockedUsers(prev => prev.filter(block => block.id !== blockId)); - toast({ - title: 'User unblocked', - description: `You have unblocked @${username}` + + logger.info('User unblocked successfully', { + userId: user.id, + action: 'unblock_user', + targetUserId: blockedUserId }); - } catch (error: any) { - toast({ - title: 'Error', - description: 'Failed to unblock user', - variant: 'destructive' + + handleSuccess('User unblocked', `You have unblocked @${username}`); + } catch (error) { + logger.error('Error unblocking user', { + userId: user.id, + action: 'unblock_user', + targetUserId: blockedUserId, + error: error instanceof Error ? error.message : String(error) + }); + + handleError(error, { + action: 'Unblock user', + userId: user.id, + metadata: { targetUsername: username } }); } }; @@ -178,7 +227,11 @@ export function BlockedUsers() { Cancel handleUnblock(block.id, block.blocked_profile?.username || 'user')} + onClick={() => handleUnblock( + block.id, + block.blocked_id, + block.blocked_profile?.username || 'user' + )} > Unblock @@ -189,4 +242,4 @@ export function BlockedUsers() { ))} ); -} \ No newline at end of file +} diff --git a/src/components/settings/PrivacyTab.tsx b/src/components/settings/PrivacyTab.tsx index 1128f50d..22819342 100644 --- a/src/components/settings/PrivacyTab.tsx +++ b/src/components/settings/PrivacyTab.tsx @@ -6,156 +6,227 @@ import { Switch } from '@/components/ui/switch'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; -import { useToast } from '@/hooks/use-toast'; +import { handleError, handleSuccess, AppError } from '@/lib/errorHandler'; +import { logger } from '@/lib/logger'; import { useAuth } from '@/hooks/useAuth'; import { useProfile } from '@/hooks/useProfile'; import { supabase } from '@/integrations/supabase/client'; import { Eye, UserX, Shield, Search } from 'lucide-react'; import { BlockedUsers } from '@/components/privacy/BlockedUsers'; -interface PrivacySettings { - activity_visibility: 'public' | 'private'; - search_visibility: boolean; - show_location: boolean; - show_age: boolean; - show_avatar: boolean; - show_bio: boolean; - show_activity_stats: boolean; - show_home_park: boolean; -} -interface ProfilePrivacy { - privacy_level: 'public' | 'private'; - show_pronouns: boolean; -} +import type { PrivacySettings, PrivacyFormData } from '@/types/privacy'; +import { privacyFormSchema, privacySettingsSchema, DEFAULT_PRIVACY_SETTINGS } from '@/lib/privacyValidation'; +import { z } from 'zod'; + export function PrivacyTab() { const { user } = useAuth(); const { data: profile, refreshProfile } = useProfile(user?.id); - const { toast } = useToast(); const [loading, setLoading] = useState(false); const [preferences, setPreferences] = useState(null); - const form = useForm({ + + const form = useForm({ defaultValues: { - privacy_level: (profile?.privacy_level === 'friends' ? 'public' : profile?.privacy_level) || 'public', + privacy_level: profile?.privacy_level || 'public', show_pronouns: profile?.show_pronouns || false, - activity_visibility: 'public', - search_visibility: true, - show_location: false, - show_age: false, - show_avatar: true, - show_bio: true, - show_activity_stats: true, - show_home_park: false + ...DEFAULT_PRIVACY_SETTINGS } }); + useEffect(() => { fetchPreferences(); }, [user]); + const fetchPreferences = async () => { if (!user) return; + try { - const { - data, - error - } = await supabase.from('user_preferences').select('privacy_settings').eq('user_id', user.id).maybeSingle(); + const { data, error } = await supabase + .from('user_preferences') + .select('privacy_settings') + .eq('user_id', user.id) + .maybeSingle(); + if (error && error.code !== 'PGRST116') { - console.error('Error fetching preferences:', error); - return; + logger.error('Failed to fetch privacy preferences', { + userId: user.id, + action: 'fetch_privacy_preferences', + error: error.message, + errorCode: error.code + }); + throw error; } + if (data?.privacy_settings) { - const privacySettings = data.privacy_settings as any; - setPreferences(privacySettings); + // Validate the data before using it + const validatedSettings = privacySettingsSchema.parse(data.privacy_settings); + setPreferences(validatedSettings); + form.reset({ - privacy_level: profile?.privacy_level === 'friends' ? 'public' : profile?.privacy_level || 'public', + privacy_level: profile?.privacy_level || 'public', show_pronouns: profile?.show_pronouns || false, - ...privacySettings + ...validatedSettings }); } else { // Initialize preferences if they don't exist await initializePreferences(); } } catch (error) { - console.error('Error fetching preferences:', error); + logger.error('Error fetching privacy preferences', { + userId: user.id, + action: 'fetch_privacy_preferences', + error: error instanceof Error ? error.message : String(error) + }); + + handleError(error, { + action: 'Load privacy settings', + userId: user.id + }); } }; + const initializePreferences = async () => { if (!user) return; - const defaultSettings: PrivacySettings = { - activity_visibility: 'public', - search_visibility: true, - show_location: false, - show_age: false, - show_avatar: true, - show_bio: true, - show_activity_stats: true, - show_home_park: false - }; + try { - const { - error - } = await supabase.from('user_preferences').insert([{ - user_id: user.id, - privacy_settings: defaultSettings as any - }]); - if (error) throw error; - setPreferences(defaultSettings); + const { error } = await supabase + .from('user_preferences') + .insert([{ + user_id: user.id, + privacy_settings: DEFAULT_PRIVACY_SETTINGS + }]); + + if (error) { + logger.error('Failed to initialize privacy preferences', { + userId: user.id, + action: 'initialize_privacy_preferences', + error: error.message + }); + throw error; + } + + setPreferences(DEFAULT_PRIVACY_SETTINGS); form.reset({ - privacy_level: (profile?.privacy_level === 'friends' ? 'public' : profile?.privacy_level) || 'public', + privacy_level: profile?.privacy_level || 'public', show_pronouns: profile?.show_pronouns || false, - ...defaultSettings + ...DEFAULT_PRIVACY_SETTINGS }); } catch (error) { - console.error('Error initializing preferences:', error); + logger.error('Error initializing privacy preferences', { + userId: user.id, + action: 'initialize_privacy_preferences', + error: error instanceof Error ? error.message : String(error) + }); + + handleError(error, { + action: 'Initialize privacy settings', + userId: user.id + }); } }; - const onSubmit = async (data: any) => { - if (!user) return; - setLoading(true); - try { - // Update profile privacy settings - const { - error: profileError - } = await supabase.from('profiles').update({ - privacy_level: data.privacy_level, - show_pronouns: data.show_pronouns, - updated_at: new Date().toISOString() - }).eq('user_id', user.id); - if (profileError) throw profileError; + const onSubmit = async (data: PrivacyFormData) => { + if (!user) return; + + setLoading(true); + + try { + // Validate the form data + const validated = privacyFormSchema.parse(data); + + // Update profile privacy settings + const { error: profileError } = await supabase + .from('profiles') + .update({ + privacy_level: validated.privacy_level, + show_pronouns: validated.show_pronouns, + updated_at: new Date().toISOString() + }) + .eq('user_id', user.id); + + if (profileError) { + logger.error('Failed to update profile privacy', { + userId: user.id, + action: 'update_profile_privacy', + error: profileError.message, + errorCode: profileError.code + }); + throw profileError; + } + + // Extract privacy settings (exclude profile fields) + const { privacy_level, show_pronouns, ...privacySettings } = validated; + // Update user preferences - const privacySettings: PrivacySettings = { - activity_visibility: data.activity_visibility, - search_visibility: data.search_visibility, - show_location: data.show_location, - show_age: data.show_age, - show_avatar: data.show_avatar, - show_bio: data.show_bio, - show_activity_stats: data.show_activity_stats, - show_home_park: data.show_home_park - }; - const { - error: prefsError - } = await supabase.from('user_preferences').upsert([{ + const { error: prefsError } = await supabase + .from('user_preferences') + .upsert([{ + user_id: user.id, + privacy_settings: privacySettings, + updated_at: new Date().toISOString() + }]); + + if (prefsError) { + logger.error('Failed to update privacy preferences', { + userId: user.id, + action: 'update_privacy_preferences', + error: prefsError.message, + errorCode: prefsError.code + }); + throw prefsError; + } + + // Log to audit trail + await supabase.from('profile_audit_log').insert({ user_id: user.id, - privacy_settings: privacySettings as any, - updated_at: new Date().toISOString() - }]); - if (prefsError) throw prefsError; + changed_by: user.id, + action: 'privacy_settings_updated', + changes: { + previous: preferences, + updated: privacySettings, + timestamp: new Date().toISOString() + } + }); + await refreshProfile(); setPreferences(privacySettings); - toast({ - title: 'Privacy settings updated', - description: 'Your privacy preferences have been successfully saved.' + + logger.info('Privacy settings updated successfully', { + userId: user.id, + action: 'update_privacy_settings' }); - } catch (error: any) { - toast({ - title: 'Error', - description: error.message || 'Failed to update privacy settings', - variant: 'destructive' + + handleSuccess( + 'Privacy settings updated', + 'Your privacy preferences have been successfully saved.' + ); + } catch (error) { + logger.error('Failed to update privacy settings', { + userId: user.id, + action: 'update_privacy_settings', + error: error instanceof Error ? error.message : String(error) }); + + if (error instanceof z.ZodError) { + handleError( + new AppError( + 'Invalid privacy settings', + 'VALIDATION_ERROR', + error.errors.map(e => e.message).join(', ') + ), + { action: 'Validate privacy settings', userId: user.id } + ); + } else { + handleError(error, { + action: 'Update privacy settings', + userId: user.id + }); + } } finally { setLoading(false); } }; - return
+ + return ( +
{/* Profile Visibility */}
@@ -173,7 +244,10 @@ export function PrivacyTab() {
- form.setValue('privacy_level', value)} + > @@ -195,7 +269,10 @@ export function PrivacyTab() { Display your preferred pronouns on your profile

- form.setValue('show_pronouns', checked)} /> + form.setValue('show_pronouns', checked)} + />
@@ -205,7 +282,10 @@ export function PrivacyTab() { Display your location on your profile

- form.setValue('show_location', checked)} /> + form.setValue('show_location', checked)} + />
@@ -215,7 +295,10 @@ export function PrivacyTab() { Display your birth date on your profile

- form.setValue('show_age', checked)} /> + form.setValue('show_age', checked)} + />
@@ -225,7 +308,10 @@ export function PrivacyTab() { Display your profile picture

- form.setValue('show_avatar', checked)} /> + form.setValue('show_avatar', checked)} + />
@@ -235,7 +321,10 @@ export function PrivacyTab() { Display your profile bio/description

- form.setValue('show_bio', checked)} /> + form.setValue('show_bio', checked)} + />
@@ -245,7 +334,10 @@ export function PrivacyTab() { Display your ride counts and park visits

- form.setValue('show_activity_stats', checked)} /> + form.setValue('show_activity_stats', checked)} + />
@@ -255,7 +347,10 @@ export function PrivacyTab() { Display your home park preference

- form.setValue('show_home_park', checked)} /> + form.setValue('show_home_park', checked)} + /> @@ -280,7 +375,10 @@ export function PrivacyTab() {
- form.setValue('activity_visibility', value)} + > @@ -324,7 +422,10 @@ export function PrivacyTab() { Allow your profile to appear in search results

- form.setValue('search_visibility', checked)} /> + form.setValue('search_visibility', checked)} + />
@@ -358,5 +459,6 @@ export function PrivacyTab() { - ; -} \ No newline at end of file + + ); +} diff --git a/src/lib/privacyValidation.ts b/src/lib/privacyValidation.ts new file mode 100644 index 00000000..1cfc05e1 --- /dev/null +++ b/src/lib/privacyValidation.ts @@ -0,0 +1,70 @@ +/** + * Privacy Settings Validation + * + * Provides Zod schemas for runtime validation of privacy settings. + * + * Usage: + * ```typescript + * const validated = privacyFormSchema.parse(userInput); + * ``` + * + * Security: + * - All user inputs must be validated before database writes + * - Prevents injection attacks and data corruption + * - Ensures data integrity with type-safe validation + */ + +import { z } from 'zod'; + +/** + * Schema for privacy settings in user_preferences + */ +export const privacySettingsSchema = z.object({ + activity_visibility: z.enum(['public', 'private'], { + errorMap: () => ({ message: 'Activity visibility must be public or private' }) + }), + search_visibility: z.boolean(), + show_location: z.boolean(), + show_age: z.boolean(), + show_avatar: z.boolean(), + show_bio: z.boolean(), + show_activity_stats: z.boolean(), + show_home_park: z.boolean() +}); + +/** + * Schema for profile privacy settings + */ +export const profilePrivacySchema = z.object({ + privacy_level: z.enum(['public', 'private'], { + errorMap: () => ({ message: 'Privacy level must be public or private' }) + }), + show_pronouns: z.boolean() +}); + +/** + * Combined schema for privacy form + */ +export const privacyFormSchema = privacySettingsSchema.merge(profilePrivacySchema); + +/** + * Schema for blocking a user + */ +export const blockUserSchema = z.object({ + blocked_id: z.string().uuid('Invalid user ID'), + reason: z.string().max(500, 'Reason must be 500 characters or less').optional() +}); + +/** + * Default privacy settings for new users + */ +export const DEFAULT_PRIVACY_SETTINGS = { + activity_visibility: 'public' as const, + search_visibility: true, + show_location: false, + show_age: false, + show_avatar: true, + show_bio: true, + show_activity_stats: true, + show_home_park: false +}; diff --git a/src/types/privacy.ts b/src/types/privacy.ts new file mode 100644 index 00000000..8cf38487 --- /dev/null +++ b/src/types/privacy.ts @@ -0,0 +1,58 @@ +/** + * Privacy Types + * + * Centralized type definitions for user privacy settings. + * + * Storage: + * - ProfilePrivacySettings: stored in profiles table + * - PrivacySettings: stored in user_preferences.privacy_settings (JSONB) + * + * Security: + * - All privacy settings are validated with Zod before database writes + * - Changes are logged to profile_audit_log for compliance + * - RLS policies ensure users can only modify their own settings + */ + +/** + * Privacy settings stored in user_preferences.privacy_settings + */ +export interface PrivacySettings { + activity_visibility: 'public' | 'private'; + search_visibility: boolean; + show_location: boolean; + show_age: boolean; + show_avatar: boolean; + show_bio: boolean; + show_activity_stats: boolean; + show_home_park: boolean; +} + +/** + * Profile-level privacy settings + */ +export interface ProfilePrivacySettings { + privacy_level: 'public' | 'private'; + show_pronouns: boolean; +} + +/** + * Combined form data for privacy tab + */ +export interface PrivacyFormData extends ProfilePrivacySettings, PrivacySettings {} + +/** + * User block information + */ +export interface UserBlock { + id: string; + blocker_id: string; + blocked_id: string; + reason?: string; + created_at: string; + blocked_profile?: { + user_id: string; + username: string; + display_name?: string; + avatar_url?: string; + }; +}