mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 14:51:13 -05:00
feat: Implement privacy modernization plan
This commit is contained in:
@@ -1,29 +1,17 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||||
import { UserX, Trash2 } from 'lucide-react';
|
import { UserX, Trash2 } from 'lucide-react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
interface BlockedUser {
|
import type { UserBlock } from '@/types/privacy';
|
||||||
id: string;
|
|
||||||
blocked_id: string;
|
|
||||||
reason?: string;
|
|
||||||
created_at: string;
|
|
||||||
blocked_profile?: {
|
|
||||||
username: string;
|
|
||||||
display_name?: string;
|
|
||||||
avatar_url?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BlockedUsers() {
|
export function BlockedUsers() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { toast } = useToast();
|
const [blockedUsers, setBlockedUsers] = useState<UserBlock[]>([]);
|
||||||
const [blockedUsers, setBlockedUsers] = useState<BlockedUser[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,7 +31,15 @@ export function BlockedUsers() {
|
|||||||
.eq('blocker_id', user.id)
|
.eq('blocker_id', user.id)
|
||||||
.order('created_at', { ascending: false });
|
.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) {
|
if (!blocks || blocks.length === 0) {
|
||||||
setBlockedUsers([]);
|
setBlockedUsers([]);
|
||||||
@@ -57,46 +53,99 @@ export function BlockedUsers() {
|
|||||||
.select('user_id, username, display_name, avatar_url')
|
.select('user_id, username, display_name, avatar_url')
|
||||||
.in('user_id', blockedIds);
|
.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
|
// Combine the data
|
||||||
const blockedUsersWithProfiles = blocks.map(block => ({
|
const blockedUsersWithProfiles = blocks.map(block => ({
|
||||||
...block,
|
...block,
|
||||||
|
blocker_id: user.id,
|
||||||
blocked_profile: profiles?.find(p => p.user_id === block.blocked_id)
|
blocked_profile: profiles?.find(p => p.user_id === block.blocked_id)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setBlockedUsers(blockedUsersWithProfiles);
|
setBlockedUsers(blockedUsersWithProfiles);
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error fetching blocked users:', error);
|
logger.info('Blocked users fetched successfully', {
|
||||||
toast({
|
userId: user.id,
|
||||||
title: 'Error',
|
action: 'fetch_blocked_users',
|
||||||
description: 'Failed to load blocked users',
|
count: blockedUsersWithProfiles.length
|
||||||
variant: 'destructive'
|
});
|
||||||
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUnblock = async (blockId: string, username: string) => {
|
const handleUnblock = async (blockId: string, blockedUserId: string, username: string) => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('user_blocks')
|
.from('user_blocks')
|
||||||
.delete()
|
.delete()
|
||||||
.eq('id', blockId);
|
.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));
|
setBlockedUsers(prev => prev.filter(block => block.id !== blockId));
|
||||||
toast({
|
|
||||||
title: 'User unblocked',
|
logger.info('User unblocked successfully', {
|
||||||
description: `You have unblocked @${username}`
|
userId: user.id,
|
||||||
|
action: 'unblock_user',
|
||||||
|
targetUserId: blockedUserId
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
|
||||||
toast({
|
handleSuccess('User unblocked', `You have unblocked @${username}`);
|
||||||
title: 'Error',
|
} catch (error) {
|
||||||
description: 'Failed to unblock user',
|
logger.error('Error unblocking user', {
|
||||||
variant: 'destructive'
|
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() {
|
|||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={() => handleUnblock(block.id, block.blocked_profile?.username || 'user')}
|
onClick={() => handleUnblock(
|
||||||
|
block.id,
|
||||||
|
block.blocked_id,
|
||||||
|
block.blocked_profile?.username || 'user'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Unblock
|
Unblock
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
|
|||||||
@@ -6,156 +6,227 @@ import { Switch } from '@/components/ui/switch';
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Separator } from '@/components/ui/separator';
|
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 { useAuth } from '@/hooks/useAuth';
|
||||||
import { useProfile } from '@/hooks/useProfile';
|
import { useProfile } from '@/hooks/useProfile';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { Eye, UserX, Shield, Search } from 'lucide-react';
|
import { Eye, UserX, Shield, Search } from 'lucide-react';
|
||||||
import { BlockedUsers } from '@/components/privacy/BlockedUsers';
|
import { BlockedUsers } from '@/components/privacy/BlockedUsers';
|
||||||
interface PrivacySettings {
|
import type { PrivacySettings, PrivacyFormData } from '@/types/privacy';
|
||||||
activity_visibility: 'public' | 'private';
|
import { privacyFormSchema, privacySettingsSchema, DEFAULT_PRIVACY_SETTINGS } from '@/lib/privacyValidation';
|
||||||
search_visibility: boolean;
|
import { z } from 'zod';
|
||||||
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;
|
|
||||||
}
|
|
||||||
export function PrivacyTab() {
|
export function PrivacyTab() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { data: profile, refreshProfile } = useProfile(user?.id);
|
const { data: profile, refreshProfile } = useProfile(user?.id);
|
||||||
const { toast } = useToast();
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [preferences, setPreferences] = useState<PrivacySettings | null>(null);
|
const [preferences, setPreferences] = useState<PrivacySettings | null>(null);
|
||||||
const form = useForm<ProfilePrivacy & PrivacySettings>({
|
|
||||||
|
const form = useForm<PrivacyFormData>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
privacy_level: (profile?.privacy_level === 'friends' ? 'public' : profile?.privacy_level) || 'public',
|
privacy_level: profile?.privacy_level || 'public',
|
||||||
show_pronouns: profile?.show_pronouns || false,
|
show_pronouns: profile?.show_pronouns || false,
|
||||||
activity_visibility: 'public',
|
...DEFAULT_PRIVACY_SETTINGS
|
||||||
search_visibility: true,
|
|
||||||
show_location: false,
|
|
||||||
show_age: false,
|
|
||||||
show_avatar: true,
|
|
||||||
show_bio: true,
|
|
||||||
show_activity_stats: true,
|
|
||||||
show_home_park: false
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPreferences();
|
fetchPreferences();
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const fetchPreferences = async () => {
|
const fetchPreferences = async () => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const {
|
const { data, error } = await supabase
|
||||||
data,
|
.from('user_preferences')
|
||||||
error
|
.select('privacy_settings')
|
||||||
} = await supabase.from('user_preferences').select('privacy_settings').eq('user_id', user.id).maybeSingle();
|
.eq('user_id', user.id)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
if (error && error.code !== 'PGRST116') {
|
if (error && error.code !== 'PGRST116') {
|
||||||
console.error('Error fetching preferences:', error);
|
logger.error('Failed to fetch privacy preferences', {
|
||||||
return;
|
userId: user.id,
|
||||||
|
action: 'fetch_privacy_preferences',
|
||||||
|
error: error.message,
|
||||||
|
errorCode: error.code
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data?.privacy_settings) {
|
if (data?.privacy_settings) {
|
||||||
const privacySettings = data.privacy_settings as any;
|
// Validate the data before using it
|
||||||
setPreferences(privacySettings);
|
const validatedSettings = privacySettingsSchema.parse(data.privacy_settings);
|
||||||
|
setPreferences(validatedSettings);
|
||||||
|
|
||||||
form.reset({
|
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,
|
show_pronouns: profile?.show_pronouns || false,
|
||||||
...privacySettings
|
...validatedSettings
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Initialize preferences if they don't exist
|
// Initialize preferences if they don't exist
|
||||||
await initializePreferences();
|
await initializePreferences();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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 () => {
|
const initializePreferences = async () => {
|
||||||
if (!user) return;
|
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 {
|
try {
|
||||||
const {
|
const { error } = await supabase
|
||||||
error
|
.from('user_preferences')
|
||||||
} = await supabase.from('user_preferences').insert([{
|
.insert([{
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
privacy_settings: defaultSettings as any
|
privacy_settings: DEFAULT_PRIVACY_SETTINGS
|
||||||
}]);
|
}]);
|
||||||
if (error) throw error;
|
|
||||||
setPreferences(defaultSettings);
|
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({
|
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,
|
show_pronouns: profile?.show_pronouns || false,
|
||||||
...defaultSettings
|
...DEFAULT_PRIVACY_SETTINGS
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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) => {
|
|
||||||
|
const onSubmit = async (data: PrivacyFormData) => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Validate the form data
|
||||||
|
const validated = privacyFormSchema.parse(data);
|
||||||
|
|
||||||
// Update profile privacy settings
|
// Update profile privacy settings
|
||||||
const {
|
const { error: profileError } = await supabase
|
||||||
error: profileError
|
.from('profiles')
|
||||||
} = await supabase.from('profiles').update({
|
.update({
|
||||||
privacy_level: data.privacy_level,
|
privacy_level: validated.privacy_level,
|
||||||
show_pronouns: data.show_pronouns,
|
show_pronouns: validated.show_pronouns,
|
||||||
updated_at: new Date().toISOString()
|
updated_at: new Date().toISOString()
|
||||||
}).eq('user_id', user.id);
|
})
|
||||||
if (profileError) throw profileError;
|
.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
|
// Update user preferences
|
||||||
const privacySettings: PrivacySettings = {
|
const { error: prefsError } = await supabase
|
||||||
activity_visibility: data.activity_visibility,
|
.from('user_preferences')
|
||||||
search_visibility: data.search_visibility,
|
.upsert([{
|
||||||
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([{
|
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
privacy_settings: privacySettings as any,
|
privacy_settings: privacySettings,
|
||||||
updated_at: new Date().toISOString()
|
updated_at: new Date().toISOString()
|
||||||
}]);
|
}]);
|
||||||
if (prefsError) throw prefsError;
|
|
||||||
|
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,
|
||||||
|
changed_by: user.id,
|
||||||
|
action: 'privacy_settings_updated',
|
||||||
|
changes: {
|
||||||
|
previous: preferences,
|
||||||
|
updated: privacySettings,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await refreshProfile();
|
await refreshProfile();
|
||||||
setPreferences(privacySettings);
|
setPreferences(privacySettings);
|
||||||
toast({
|
|
||||||
title: 'Privacy settings updated',
|
logger.info('Privacy settings updated successfully', {
|
||||||
description: 'Your privacy preferences have been successfully saved.'
|
userId: user.id,
|
||||||
|
action: 'update_privacy_settings'
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
|
||||||
toast({
|
handleSuccess(
|
||||||
title: 'Error',
|
'Privacy settings updated',
|
||||||
description: error.message || 'Failed to update privacy settings',
|
'Your privacy preferences have been successfully saved.'
|
||||||
variant: 'destructive'
|
);
|
||||||
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return <div className="space-y-8">
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||||
{/* Profile Visibility */}
|
{/* Profile Visibility */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -173,7 +244,10 @@ export function PrivacyTab() {
|
|||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label htmlFor="privacy_level">Profile Privacy</Label>
|
<Label htmlFor="privacy_level">Profile Privacy</Label>
|
||||||
<Select value={form.watch('privacy_level')} onValueChange={(value: 'public' | 'private') => form.setValue('privacy_level', value)}>
|
<Select
|
||||||
|
value={form.watch('privacy_level')}
|
||||||
|
onValueChange={(value: 'public' | 'private') => form.setValue('privacy_level', value)}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -195,7 +269,10 @@ export function PrivacyTab() {
|
|||||||
Display your preferred pronouns on your profile
|
Display your preferred pronouns on your profile
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch checked={form.watch('show_pronouns')} onCheckedChange={checked => form.setValue('show_pronouns', checked)} />
|
<Switch
|
||||||
|
checked={form.watch('show_pronouns')}
|
||||||
|
onCheckedChange={checked => form.setValue('show_pronouns', checked)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -205,7 +282,10 @@ export function PrivacyTab() {
|
|||||||
Display your location on your profile
|
Display your location on your profile
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch checked={form.watch('show_location')} onCheckedChange={checked => form.setValue('show_location', checked)} />
|
<Switch
|
||||||
|
checked={form.watch('show_location')}
|
||||||
|
onCheckedChange={checked => form.setValue('show_location', checked)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -215,7 +295,10 @@ export function PrivacyTab() {
|
|||||||
Display your birth date on your profile
|
Display your birth date on your profile
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch checked={form.watch('show_age')} onCheckedChange={checked => form.setValue('show_age', checked)} />
|
<Switch
|
||||||
|
checked={form.watch('show_age')}
|
||||||
|
onCheckedChange={checked => form.setValue('show_age', checked)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -225,7 +308,10 @@ export function PrivacyTab() {
|
|||||||
Display your profile picture
|
Display your profile picture
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch checked={form.watch('show_avatar')} onCheckedChange={checked => form.setValue('show_avatar', checked)} />
|
<Switch
|
||||||
|
checked={form.watch('show_avatar')}
|
||||||
|
onCheckedChange={checked => form.setValue('show_avatar', checked)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -235,7 +321,10 @@ export function PrivacyTab() {
|
|||||||
Display your profile bio/description
|
Display your profile bio/description
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch checked={form.watch('show_bio')} onCheckedChange={checked => form.setValue('show_bio', checked)} />
|
<Switch
|
||||||
|
checked={form.watch('show_bio')}
|
||||||
|
onCheckedChange={checked => form.setValue('show_bio', checked)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -245,7 +334,10 @@ export function PrivacyTab() {
|
|||||||
Display your ride counts and park visits
|
Display your ride counts and park visits
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch checked={form.watch('show_activity_stats')} onCheckedChange={checked => form.setValue('show_activity_stats', checked)} />
|
<Switch
|
||||||
|
checked={form.watch('show_activity_stats')}
|
||||||
|
onCheckedChange={checked => form.setValue('show_activity_stats', checked)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -255,7 +347,10 @@ export function PrivacyTab() {
|
|||||||
Display your home park preference
|
Display your home park preference
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch checked={form.watch('show_home_park')} onCheckedChange={checked => form.setValue('show_home_park', checked)} />
|
<Switch
|
||||||
|
checked={form.watch('show_home_park')}
|
||||||
|
onCheckedChange={checked => form.setValue('show_home_park', checked)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -280,7 +375,10 @@ export function PrivacyTab() {
|
|||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label htmlFor="activity_visibility">Activity Visibility</Label>
|
<Label htmlFor="activity_visibility">Activity Visibility</Label>
|
||||||
<Select value={form.watch('activity_visibility')} onValueChange={(value: 'public' | 'private') => form.setValue('activity_visibility', value)}>
|
<Select
|
||||||
|
value={form.watch('activity_visibility')}
|
||||||
|
onValueChange={(value: 'public' | 'private') => form.setValue('activity_visibility', value)}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -324,7 +422,10 @@ export function PrivacyTab() {
|
|||||||
Allow your profile to appear in search results
|
Allow your profile to appear in search results
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch checked={form.watch('search_visibility')} onCheckedChange={checked => form.setValue('search_visibility', checked)} />
|
<Switch
|
||||||
|
checked={form.watch('search_visibility')}
|
||||||
|
onCheckedChange={checked => form.setValue('search_visibility', checked)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -358,5 +459,6 @@ export function PrivacyTab() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>;
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
70
src/lib/privacyValidation.ts
Normal file
70
src/lib/privacyValidation.ts
Normal file
@@ -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
|
||||||
|
};
|
||||||
58
src/types/privacy.ts
Normal file
58
src/types/privacy.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user