mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 07:11:12 -05:00
Refine profile privacy controls
This commit is contained in:
@@ -16,6 +16,10 @@ interface PrivacySettings {
|
||||
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';
|
||||
@@ -39,7 +43,11 @@ export function PrivacyTab() {
|
||||
activity_visibility: 'public',
|
||||
search_visibility: true,
|
||||
show_location: false,
|
||||
show_age: false
|
||||
show_age: false,
|
||||
show_avatar: true,
|
||||
show_bio: true,
|
||||
show_activity_stats: true,
|
||||
show_home_park: false
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
@@ -78,7 +86,11 @@ export function PrivacyTab() {
|
||||
activity_visibility: 'public',
|
||||
search_visibility: true,
|
||||
show_location: false,
|
||||
show_age: false
|
||||
show_age: false,
|
||||
show_avatar: true,
|
||||
show_bio: true,
|
||||
show_activity_stats: true,
|
||||
show_home_park: false
|
||||
};
|
||||
try {
|
||||
const {
|
||||
@@ -117,7 +129,11 @@ export function PrivacyTab() {
|
||||
activity_visibility: data.activity_visibility,
|
||||
search_visibility: data.search_visibility,
|
||||
show_location: data.show_location,
|
||||
show_age: data.show_age
|
||||
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
|
||||
@@ -196,6 +212,55 @@ export function PrivacyTab() {
|
||||
<Switch checked={form.watch('show_location')} onCheckedChange={checked => form.setValue('show_location', checked)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Show Age/Birth Date</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Display your birth date on your profile
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={form.watch('show_age')} onCheckedChange={checked => form.setValue('show_age', checked)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Show Avatar</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Display your profile picture
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={form.watch('show_avatar')} onCheckedChange={checked => form.setValue('show_avatar', checked)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Show Bio</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Display your profile bio/description
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={form.watch('show_bio')} onCheckedChange={checked => form.setValue('show_bio', checked)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Show Activity Statistics</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Display your ride counts and park visits
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={form.watch('show_activity_stats')} onCheckedChange={checked => form.setValue('show_activity_stats', checked)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Show Home Park</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Display your home park preference
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={form.watch('show_home_park')} onCheckedChange={checked => form.setValue('show_home_park', checked)} />
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
94
src/hooks/useProfileFieldAccess.ts
Normal file
94
src/hooks/useProfileFieldAccess.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
interface ProfileFieldAccess {
|
||||
[fieldName: string]: boolean;
|
||||
}
|
||||
|
||||
export function useProfileFieldAccess(profileUserId: string | null | undefined) {
|
||||
const { user } = useAuth();
|
||||
const [fieldAccess, setFieldAccess] = useState<ProfileFieldAccess>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileUserId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
checkFieldAccess();
|
||||
}, [profileUserId, user?.id]);
|
||||
|
||||
const checkFieldAccess = async () => {
|
||||
if (!profileUserId || !user?.id) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fields that might need privacy checking
|
||||
const fieldsToCheck = [
|
||||
'date_of_birth',
|
||||
'personal_location',
|
||||
'location_id',
|
||||
'preferred_pronouns',
|
||||
'home_park_id',
|
||||
'bio',
|
||||
'avatar_url',
|
||||
'avatar_image_id'
|
||||
];
|
||||
|
||||
const accessChecks: ProfileFieldAccess = {};
|
||||
|
||||
// Check each field individually using our security definer function
|
||||
for (const field of fieldsToCheck) {
|
||||
const { data, error } = await supabase.rpc('can_view_profile_field', {
|
||||
_viewer_id: user.id,
|
||||
_profile_user_id: profileUserId,
|
||||
_field_name: field
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(`Error checking access for field ${field}:`, error);
|
||||
accessChecks[field] = false;
|
||||
} else {
|
||||
accessChecks[field] = data === true;
|
||||
}
|
||||
}
|
||||
|
||||
setFieldAccess(accessChecks);
|
||||
} catch (error) {
|
||||
console.error('Error checking field access:', error);
|
||||
// Default to denying access on error
|
||||
setFieldAccess({});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canViewField = (fieldName: string): boolean => {
|
||||
if (!profileUserId || !user?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Users can always see their own fields
|
||||
if (user.id === profileUserId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return fieldAccess[fieldName] || false;
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
checkFieldAccess();
|
||||
};
|
||||
|
||||
return {
|
||||
canViewField,
|
||||
loading,
|
||||
refresh
|
||||
};
|
||||
}
|
||||
@@ -949,32 +949,7 @@ export type Database = {
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
public_profiles: {
|
||||
Row: {
|
||||
avatar_image_id: string | null
|
||||
avatar_url: string | null
|
||||
bio: string | null
|
||||
coaster_count: number | null
|
||||
created_at: string | null
|
||||
date_of_birth: string | null
|
||||
display_name: string | null
|
||||
home_park_id: string | null
|
||||
id: string | null
|
||||
location_id: string | null
|
||||
park_count: number | null
|
||||
personal_location: string | null
|
||||
preferred_pronouns: string | null
|
||||
privacy_level: string | null
|
||||
reputation_score: number | null
|
||||
review_count: number | null
|
||||
ride_count: number | null
|
||||
show_pronouns: boolean | null
|
||||
updated_at: string | null
|
||||
user_id: string | null
|
||||
username: string | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
[_ in never]: never
|
||||
}
|
||||
Functions: {
|
||||
can_manage_user: {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { profileEditSchema } from '@/lib/validation';
|
||||
import { LocationDisplay } from '@/components/profile/LocationDisplay';
|
||||
import { UserBlockButton } from '@/components/profile/UserBlockButton';
|
||||
import { PersonalLocationDisplay } from '@/components/profile/PersonalLocationDisplay';
|
||||
import { useProfileFieldAccess } from '@/hooks/useProfileFieldAccess';
|
||||
export default function Profile() {
|
||||
const {
|
||||
username
|
||||
@@ -54,6 +55,9 @@ export default function Profile() {
|
||||
parkCount: 0
|
||||
});
|
||||
|
||||
// Profile field access checking
|
||||
const { canViewField, loading: fieldAccessLoading } = useProfileFieldAccess(profile?.user_id);
|
||||
|
||||
// Username validation
|
||||
const usernameValidation = useUsernameValidation(editForm.username, profile?.username);
|
||||
useEffect(() => {
|
||||
@@ -349,13 +353,22 @@ export default function Profile() {
|
||||
<CardContent className="p-8">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="flex flex-col items-center md:items-start">
|
||||
<PhotoUpload variant="avatar" maxFiles={1} maxSizeMB={1} existingPhotos={profile.avatar_url ? [profile.avatar_url] : []} onUploadComplete={handleAvatarUpload} currentImageId={avatarImageId} onError={error => {
|
||||
toast({
|
||||
title: "Upload Error",
|
||||
description: error,
|
||||
variant: "destructive"
|
||||
});
|
||||
}} className="mb-4" />
|
||||
<PhotoUpload
|
||||
variant="avatar"
|
||||
maxFiles={1}
|
||||
maxSizeMB={1}
|
||||
existingPhotos={canViewField('avatar_url') && profile.avatar_url ? [profile.avatar_url] : []}
|
||||
onUploadComplete={handleAvatarUpload}
|
||||
currentImageId={avatarImageId}
|
||||
onError={error => {
|
||||
toast({
|
||||
title: "Upload Error",
|
||||
description: error,
|
||||
variant: "destructive"
|
||||
});
|
||||
}}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
{isOwnProfile && !editing && <Button variant="outline" size="sm" onClick={() => setEditing(true)}>
|
||||
@@ -432,9 +445,11 @@ export default function Profile() {
|
||||
{profile.display_name && <Badge variant="secondary" className="cursor-pointer hover:bg-secondary/80" onClick={() => navigate(`/profile/${profile.username}`)}>@{profile.username}</Badge>}
|
||||
</div>
|
||||
|
||||
{profile.bio && <p className="text-muted-foreground mb-4 max-w-2xl">
|
||||
{canViewField('bio') && profile.bio && (
|
||||
<p className="text-muted-foreground mb-4 max-w-2xl">
|
||||
{profile.bio}
|
||||
</p>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -445,17 +460,31 @@ export default function Profile() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Show pronouns if enabled */}
|
||||
{profile.show_pronouns && profile.preferred_pronouns && <div className="flex items-center gap-1">
|
||||
{/* Show pronouns if enabled and privacy allows */}
|
||||
{profile.show_pronouns && canViewField('preferred_pronouns') && profile.preferred_pronouns && (
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="w-4 h-4" />
|
||||
{profile.preferred_pronouns}
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show personal location if available and privacy allows */}
|
||||
{profile.personal_location && <PersonalLocationDisplay personalLocation={profile.personal_location} userId={profile.user_id} isOwnProfile={isOwnProfile} />}
|
||||
{canViewField('personal_location') && profile.personal_location && (
|
||||
<PersonalLocationDisplay
|
||||
personalLocation={profile.personal_location}
|
||||
userId={profile.user_id}
|
||||
isOwnProfile={isOwnProfile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Show location only if privacy allows */}
|
||||
{profile.location && <LocationDisplay location={profile.location} userId={profile.user_id} isOwnProfile={isOwnProfile} />}
|
||||
{canViewField('location_id') && profile.location && (
|
||||
<LocationDisplay
|
||||
location={profile.location}
|
||||
userId={profile.user_id}
|
||||
isOwnProfile={isOwnProfile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Drop the problematic view that's causing security issues
|
||||
DROP VIEW IF EXISTS public.public_profiles;
|
||||
|
||||
-- Instead, let's create a simpler and safer approach using just RLS policies
|
||||
-- The view was too complex and triggered security warnings
|
||||
|
||||
-- Remove the overly permissive policy I created earlier
|
||||
DROP POLICY IF EXISTS "Allow viewing basic profile info" ON public.profiles;
|
||||
|
||||
-- Create more specific, safer RLS policies for different use cases
|
||||
|
||||
-- Policy for users to view their own complete profile
|
||||
CREATE POLICY "Users can view their own complete profile"
|
||||
ON public.profiles
|
||||
FOR SELECT
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- Policy for moderators/admins to view all profiles
|
||||
CREATE POLICY "Moderators can view all profiles"
|
||||
ON public.profiles
|
||||
FOR SELECT
|
||||
USING (is_moderator(auth.uid()));
|
||||
|
||||
-- Policy for public access to profiles - but ONLY basic safe fields are accessible via application logic
|
||||
-- This policy allows the row to be returned, but sensitive fields should be filtered out by application code
|
||||
CREATE POLICY "Public access to non-private profiles with field restrictions"
|
||||
ON public.profiles
|
||||
FOR SELECT
|
||||
USING (
|
||||
privacy_level = 'public'
|
||||
AND NOT banned
|
||||
);
|
||||
|
||||
-- The security will be enforced in the application layer using our can_view_profile_field function
|
||||
-- This approach is safer than trying to restrict field access in RLS policies
|
||||
Reference in New Issue
Block a user