diff --git a/apps/web/src/features/profile/pages/user-profile-page/UserProfilePage.tsx b/apps/web/src/features/profile/pages/user-profile-page/UserProfilePage.tsx index 58072c442..829df24d1 100644 --- a/apps/web/src/features/profile/pages/user-profile-page/UserProfilePage.tsx +++ b/apps/web/src/features/profile/pages/user-profile-page/UserProfilePage.tsx @@ -4,6 +4,7 @@ import { useUserProfilePage } from './useUserProfilePage'; import { UserProfilePageHero } from './UserProfilePageHero'; import { UserProfilePageError } from './UserProfilePageError'; import { UserProfilePageHeader } from './UserProfilePageHeader'; +import { UserProfilePagePrivate } from './UserProfilePagePrivate'; import { UserProfilePageTabs } from './UserProfilePageTabs'; import { UserProfilePageSkeleton } from './UserProfilePageSkeleton'; @@ -15,6 +16,7 @@ export function UserProfilePage() { isLoading, error, isNotFound, + isPrivateView, tracksData, playlistsData, postsData, @@ -43,6 +45,16 @@ export function UserProfilePage() { return window.location.reload()} />; } + if (isPrivateView) { + return ( + + ); + } + return (
diff --git a/apps/web/src/features/profile/pages/user-profile-page/UserProfilePagePrivate.tsx b/apps/web/src/features/profile/pages/user-profile-page/UserProfilePagePrivate.tsx new file mode 100644 index 000000000..960c222f3 --- /dev/null +++ b/apps/web/src/features/profile/pages/user-profile-page/UserProfilePagePrivate.tsx @@ -0,0 +1,62 @@ +/** + * B3: Vue "Profil privé" quand le profil est masqué et le visiteur n'est pas le propriétaire. + */ +import { EyeOff } from 'lucide-react'; +import { Avatar } from '@/components/ui/avatar'; +import { Card, CardContent } from '@/components/ui/card'; +import { UserProfilePageHero } from './UserProfilePageHero'; +import type { UserProfile } from '@/services/api/users'; + +interface UserProfilePagePrivateProps { + profile: UserProfile; + displayName: string; + initials: string; +} + +export function UserProfilePagePrivate({ + profile, + displayName, + initials, +}: UserProfilePagePrivateProps) { + return ( +
+ +
+ + +
+
+ +
+
+

+ {displayName} +

+

@{profile.username}

+
+
+ +
+ +

+ Profil privé +

+

+ Ce profil est masqué. Son contenu n'est pas visible. +

+
+
+
+
+
+ ); +} diff --git a/apps/web/src/features/profile/pages/user-profile-page/useUserProfilePage.ts b/apps/web/src/features/profile/pages/user-profile-page/useUserProfilePage.ts index 11a1a3ba1..5adaedb77 100644 --- a/apps/web/src/features/profile/pages/user-profile-page/useUserProfilePage.ts +++ b/apps/web/src/features/profile/pages/user-profile-page/useUserProfilePage.ts @@ -1,6 +1,7 @@ import { useParams } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { format } from 'date-fns'; +import { useUser } from '@/features/auth/hooks/useUser'; import { usersApi, type UserProfile } from '@/services/api/users'; import { tracksApi } from '@/services/api/tracks'; import { playlistsApi } from '@/services/api/playlists'; @@ -9,6 +10,7 @@ import { getUserRoles } from '@/features/roles/services/roleService'; export function useUserProfilePage() { const { username } = useParams<{ username: string }>(); + const { data: currentUser } = useUser(); const { data: profile, @@ -76,6 +78,12 @@ export function useUserProfilePage() { typeof (error as unknown as { response?: { status?: number } })?.response?.status === 'number' && (error as unknown as { response: { status: number } }).response.status === 404; + const isPrivateView = + !!profile && + profile.is_public === false && + !!currentUser && + profile.id !== currentUser.id; + return { username: username ?? null, profile, @@ -92,5 +100,6 @@ export function useUserProfilePage() { displayName, initials, memberSince, + isPrivateView, }; } diff --git a/apps/web/src/features/profile/services/profileService.ts b/apps/web/src/features/profile/services/profileService.ts index 1597df52a..06af261fa 100644 --- a/apps/web/src/features/profile/services/profileService.ts +++ b/apps/web/src/features/profile/services/profileService.ts @@ -15,11 +15,12 @@ export interface UserProfile { followers_count?: number; following_count?: number; social_links?: Record; + is_public?: boolean; } export async function getProfile(userId: string): Promise { const response = await apiClient.get(`/users/${userId}`); - return response.data.profile; + return (response.data as { profile?: UserProfile })?.profile ?? (response.data as UserProfile); } export async function getProfileByUsername( @@ -41,6 +42,7 @@ export interface UpdateProfileRequest { birthdate?: string; gender?: string; social_links?: Record; + is_public?: boolean; } export async function updateProfile( diff --git a/apps/web/src/features/settings/components/ProfileVisibilityCard.tsx b/apps/web/src/features/settings/components/ProfileVisibilityCard.tsx new file mode 100644 index 000000000..a11e2ef19 --- /dev/null +++ b/apps/web/src/features/settings/components/ProfileVisibilityCard.tsx @@ -0,0 +1,84 @@ +/** + * B3: Toggle "Profil public" dans Settings → Confidentialité. + * Persiste via updateProfile(is_public). + */ +import { useUser } from '@/features/auth/hooks/useUser'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { usersApi } from '@/services/api/users'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Eye, EyeOff } from 'lucide-react'; +import toast from '@/utils/toast'; + +export function ProfileVisibilityCard() { + const { data: user } = useUser(); + const queryClient = useQueryClient(); + + const { data: profile, isLoading } = useQuery({ + queryKey: ['profile', user?.id], + queryFn: () => usersApi.getProfile(user!.id), + enabled: !!user?.id, + }); + + const mutation = useMutation({ + mutationFn: (isPublic: boolean) => + usersApi.updateProfile(user!.id, { is_public: isPublic }), + onSuccess: (updatedProfile) => { + queryClient.setQueryData(['profile', user?.id], updatedProfile); + queryClient.invalidateQueries({ queryKey: ['userProfile'] }); + toast.success( + updatedProfile?.is_public !== false + ? 'Profil rendu public' + : 'Profil rendu privé', + ); + }, + onError: () => { + toast.error('Impossible de modifier la visibilité du profil'); + }, + }); + + const isPublic = profile?.is_public ?? true; + + if (!user?.id) return null; + + if (isLoading) { + return ( +
+ + +
+ ); + } + + return ( +
+
+ {isPublic ? ( + + ) : ( + + )} +

Visibilité du profil

+
+
+
+ +

+ {isPublic + ? 'Votre profil est visible par tous. Désactivez pour le masquer.' + : 'Votre profil est masqué. Les autres voient uniquement "Profil privé".'} +

+
+ mutation.mutate(checked === true)} + /> +
+
+ ); +} diff --git a/apps/web/src/features/settings/components/SettingsTabs.tsx b/apps/web/src/features/settings/components/SettingsTabs.tsx index 8395d2ae7..1c98df92e 100644 --- a/apps/web/src/features/settings/components/SettingsTabs.tsx +++ b/apps/web/src/features/settings/components/SettingsTabs.tsx @@ -6,6 +6,7 @@ import { import { PreferenceSettings } from './PreferenceSettings'; import { NotificationSettings } from './NotificationSettings'; import { PrivacySettings } from './PrivacySettings'; +import { ProfileVisibilityCard } from './ProfileVisibilityCard'; import { AccountSettings } from './AccountSettings'; import { PlaybackSettings } from './PlaybackSettings'; @@ -86,7 +87,8 @@ export function SettingsTabs({ settings, onChange }: SettingsTabsProps) { /> - + + { + http.put('*/api/v1/users/:id', async ({ params, request }) => { + const body = (await request.json()) as { is_public?: boolean }; return HttpResponse.json({ success: true, - data: { id: params.id, username: 'UpdatedUser', email: 'user@example.com' }, + data: { + id: params.id, + username: 'UpdatedUser', + email: 'user@example.com', + is_public: body?.is_public ?? true, + }, }); }), diff --git a/veza-backend-api/internal/handlers/profile_handler.go b/veza-backend-api/internal/handlers/profile_handler.go index dd70b9d8a..b43103c99 100644 --- a/veza-backend-api/internal/handlers/profile_handler.go +++ b/veza-backend-api/internal/handlers/profile_handler.go @@ -510,7 +510,8 @@ type UpdateProfileRequest struct { Birthdate string `json:"birthdate" binding:"omitempty,datetime=2006-01-02" validate:"omitempty,datetime=2006-01-02"` Gender string `json:"gender" binding:"omitempty,oneof=Male Female Other 'Prefer not to say'" validate:"omitempty,oneof=Male Female Other 'Prefer not to say'"` SocialLinks map[string]interface{} `json:"social_links" binding:"omitempty"` - BannerURL string `json:"banner_url" binding:"omitempty,max=2048"` + BannerURL string `json:"banner_url" binding:"omitempty,max=2048"` + IsPublic *bool `json:"is_public" binding:"omitempty"` } // UpdateProfile updates a user profile @@ -646,6 +647,9 @@ func (h *ProfileHandler) UpdateProfile(c *gin.Context) { birthdateStr := birthdate.Format("2006-01-02") serviceReq.BirthDate = &birthdateStr } + if req.IsPublic != nil { + serviceReq.IsPublic = req.IsPublic + } // Update profile using the new UpdateProfile method profile, err := h.userService.UpdateProfile(userID, serviceReq) diff --git a/veza-backend-api/internal/services/user_service.go b/veza-backend-api/internal/services/user_service.go index ef2927092..a0d59b8bd 100644 --- a/veza-backend-api/internal/services/user_service.go +++ b/veza-backend-api/internal/services/user_service.go @@ -67,7 +67,8 @@ type Profile struct { Birthdate *string `json:"birthdate"` Gender *string `json:"gender"` SocialLinks map[string]interface{} `json:"social_links"` - CreatedAt time.Time `json:"created_at"` + IsPublic bool `json:"is_public"` + CreatedAt time.Time `json:"created_at"` } // UserStats est maintenant défini dans internal/types/stats.go @@ -215,6 +216,7 @@ func (s *UserService) GetProfile(userID uuid.UUID, requesterID *uuid.UUID) (*Pro profile.Location = nil profile.Birthdate = nil profile.Gender = nil + profile.SocialLinks = nil } // Cache the profile @@ -301,6 +303,9 @@ func (s *UserService) UpdateProfile(userID uuid.UUID, req types.UpdateProfileReq if req.BannerURL != nil { updates["banner_url"] = *req.BannerURL } + if req.IsPublic != nil { + updates["is_public"] = *req.IsPublic + } // Apply updates to user object if firstname, ok := updates["first_name"].(string); ok { @@ -338,6 +343,9 @@ func (s *UserService) UpdateProfile(userID uuid.UUID, req types.UpdateProfileReq if bannerURL, ok := updates["banner_url"].(string); ok { user.BannerURL = bannerURL } + if isPublic, ok := updates["is_public"].(bool); ok { + user.IsPublic = isPublic + } // Save changes err = s.userRepo.Update(user) @@ -400,6 +408,7 @@ func (s *UserService) userToProfile(user *models.User) *Profile { Birthdate: birthdate, Gender: gender, SocialLinks: socialLinks, + IsPublic: user.IsPublic, CreatedAt: user.CreatedAt, } } diff --git a/veza-backend-api/internal/types/user.go b/veza-backend-api/internal/types/user.go index 9087744b3..7691f0e55 100644 --- a/veza-backend-api/internal/types/user.go +++ b/veza-backend-api/internal/types/user.go @@ -16,6 +16,7 @@ type UpdateProfileRequest struct { BannerURL *string `json:"banner_url"` WebsiteURL *string `json:"website_url"` ProfilePrivacy *string `json:"profile_privacy"` + IsPublic *bool `json:"is_public"` } // UserSettingsResponse represents user settings