From 571ec05d760d2f180b27bc20923f972b6c041a87 Mon Sep 17 00:00:00 2001 From: pascal Date: Thu, 13 Aug 2026 16:18:46 +0200 Subject: [PATCH] aktuazalizacja profilow,prefernecji,bezpiecznestwa --- .../polskalokalnie/auth/AuthController.java | 20 + .../pl/polskalokalnie/auth/AuthService.java | 74 +- .../auth/dto/AccountRequests.java | 4 + .../polskalokalnie/auth/dto/UserResponse.java | 12 +- .../pl/polskalokalnie/config/SchemaFixer.java | 20 + .../listing/ListingDetailResponse.java | 5 +- .../listing/ListingService.java | 46 +- .../report/ListingReportResponse.java | 12 +- .../report/ListingReportService.java | 16 +- .../report/UserReportResponse.java | 21 +- .../report/UserReportService.java | 8 +- .../java/pl/polskalokalnie/user/AppUser.java | 12 + .../user/ProfileVisibility.java | 7 +- .../user/PublicProfileController.java | 39 +- .../user/PublicProfileResponse.java | 28 +- .../user/SearchPreferencesResponse.java | 28 + .../user/UserSettingsController.java | 10 + .../user/UserSettingsService.java | 17 + frontend/src/App.tsx | 641 +++++++++++++++--- frontend/src/Avatar.tsx | 45 ++ frontend/src/auth.tsx | 42 +- frontend/src/styles.css | 383 ++++++++++- 22 files changed, 1339 insertions(+), 151 deletions(-) create mode 100644 backend/src/main/java/pl/polskalokalnie/user/SearchPreferencesResponse.java create mode 100644 frontend/src/Avatar.tsx diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AuthController.java b/backend/src/main/java/pl/polskalokalnie/auth/AuthController.java index bf4c7d7..3f0a3b6 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/AuthController.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/AuthController.java @@ -17,6 +17,7 @@ import pl.polskalokalnie.auth.dto.AccountRequests.DeleteAccountRequest; import pl.polskalokalnie.auth.dto.AccountRequests.EmailRequest; import pl.polskalokalnie.auth.dto.AccountRequests.ResetPasswordRequest; import pl.polskalokalnie.auth.dto.AccountRequests.TokenRequest; +import pl.polskalokalnie.auth.dto.AccountRequests.VerifyPasswordRequest; import pl.polskalokalnie.auth.dto.AccountRequests.VerifyPhoneRequest; import pl.polskalokalnie.auth.dto.AuthResponse; import pl.polskalokalnie.auth.dto.LoginRequest; @@ -123,6 +124,25 @@ public class AuthController { return ResponseEntity.noContent().build(); } + /** + * Sprawdza samo dotychczasowe haslo. Formularz ustawien odblokowuje pola nowego hasla dopiero + * po poprawnej odpowiedzi - blad 400 oznacza zle haslo. + */ + @PostMapping("/verify-password") + public ResponseEntity verifyPassword(Authentication authentication, @Valid @RequestBody VerifyPasswordRequest request) { + authService.verifyCurrentPassword(authentication.getName(), request.password()); + return ResponseEntity.noContent().build(); + } + + /** Ile sekund zostalo do momentu, w ktorym mozna ponownie zmienic haslo (0 = mozna teraz). */ + @GetMapping("/password-cooldown") + public PasswordCooldownResponse passwordCooldown(Authentication authentication) { + return new PasswordCooldownResponse(authService.passwordCooldownSeconds(authentication.getName())); + } + + public record PasswordCooldownResponse(long secondsLeft) { + } + @DeleteMapping("/me") public ResponseEntity deleteMe(Authentication authentication, @Valid @RequestBody DeleteAccountRequest request) { authService.deleteOwnAccount(authentication.getName(), request.password()); diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java index 29583ef..156b4c7 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java @@ -1,6 +1,7 @@ package pl.polskalokalnie.auth; import java.security.SecureRandom; +import java.time.Duration; import java.time.Instant; import java.time.LocalDate; import java.time.format.DateTimeParseException; @@ -38,6 +39,8 @@ public class AuthService { private static final SecureRandom TOKEN_RANDOM = new SecureRandom(); private static final int ACTIVATION_TTL_MINUTES = 48 * 60; private static final int RESET_TTL_MINUTES = 60; + // Odstep miedzy kolejnymi zmianami hasla z poziomu ustawien konta. + private static final int PASSWORD_CHANGE_COOLDOWN_MINUTES = 5; private final UserRepository userRepository; private final BlockedEmailRepository blockedEmailRepository; @@ -297,10 +300,19 @@ public class AuthService { public UserResponse currentUser(String email) { return userRepository.findByEmailIgnoreCase(email) - .map(UserResponse::from) + .map(this::withAvatar) .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła")); } + /** + * Profil zalogowanego uzytkownika razem ze zdjeciem z ustawien konta. Dzieki temu naglowek, + * panel konta i kazde inne miejsce pokazujace zalogowanego uzytkownika maja zdjecie od razu, + * bez osobnego zapytania o pelne ustawienia. + */ + private UserResponse withAvatar(AppUser user) { + return UserResponse.from(user, userSettingsService.avatarFor(user.getId())); + } + public UserResponse updateProfile(String email, UpdateProfileRequest request) { AppUser user = userRepository.findByEmailIgnoreCase(email) .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła")); @@ -334,7 +346,7 @@ public class AuthService { AppUser saved = userRepository.save(user); leadSyncService.syncUser(saved); - return UserResponse.from(saved); + return withAvatar(saved); } /** @@ -350,6 +362,16 @@ public class AuthService { throw new ResponseStatusException(HttpStatus.CONFLICT, "To konto loguje się przez zewnętrznego dostawcę i nie ma hasła w serwisie"); } + + // Odstep miedzy zmianami pilnuje serwer, a nie licznik w przegladarce - blokada w interfejsie + // jest tylko podpowiedzia i mozna ja obejsc wolajac API wprost. + long waitSeconds = passwordCooldownSeconds(user); + if (waitSeconds > 0) { + throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, + "Hasło można zmieniać co " + PASSWORD_CHANGE_COOLDOWN_MINUTES + + " minut. Kolejna zmiana będzie możliwa za " + formatWait(waitSeconds) + "."); + } + if (!passwordEncoder.matches(currentPassword, user.getPasswordHash())) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Dotychczasowe hasło jest nieprawidłowe"); } @@ -358,6 +380,7 @@ public class AuthService { } user.setPasswordHash(passwordEncoder.encode(newPassword)); + user.setPasswordChangedAt(Instant.now()); userRepository.save(user); // Niewykorzystane linki resetu przestaja dzialac po recznej zmianie hasla. @@ -369,6 +392,51 @@ public class AuthService { }); } + /** + * Sprawdzenie dotychczasowego hasla bez zmiany czegokolwiek. Formularz ustawien odblokowuje + * pola nowego hasla dopiero, gdy obecne jest poprawne - dzieki temu uzytkownik nie wypelnia + * calego formularza po to, zeby dowiedziec sie, ze pomylil pierwsze pole. + */ + public void verifyCurrentPassword(String email, String password) { + AppUser user = userRepository.findByEmailIgnoreCase(email) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła")); + + if (user.getPasswordHash() == null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "To konto loguje się przez zewnętrznego dostawcę i nie ma hasła w serwisie"); + } + if (password == null || !passwordEncoder.matches(password, user.getPasswordHash())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Dotychczasowe hasło jest nieprawidłowe"); + } + } + + /** Ile sekund zostalo do momentu, w ktorym mozna ponownie zmienic haslo. Zero = mozna od razu. */ + public long passwordCooldownSeconds(String email) { + return userRepository.findByEmailIgnoreCase(email) + .map(this::passwordCooldownSeconds) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła")); + } + + private long passwordCooldownSeconds(AppUser user) { + Instant last = user.getPasswordChangedAt(); + if (last == null) { + return 0; + } + long elapsed = Duration.between(last, Instant.now()).getSeconds(); + long total = PASSWORD_CHANGE_COOLDOWN_MINUTES * 60L; + return elapsed >= total ? 0 : total - elapsed; + } + + // "4 min 12 s" zamiast "252 s" - komunikat czyta czlowiek, nie licznik. + private static String formatWait(long seconds) { + long minutes = seconds / 60; + long rest = seconds % 60; + if (minutes == 0) { + return rest + " s"; + } + return minutes + " min " + rest + " s"; + } + /** * Usuniecie wlasnego konta. Konta administratorow zostawiamy - tak samo jak przy usuwaniu * z panelu admina - zeby nie dalo sie zlikwidowac ostatniego dostepu do panelu. @@ -390,7 +458,7 @@ public class AuthService { } private AuthResponse buildAuthResponse(AppUser user) { - return new AuthResponse(jwtService.generateToken(user), UserResponse.from(user)); + return new AuthResponse(jwtService.generateToken(user), withAvatar(user)); } private String normalizeEmail(String email) { diff --git a/backend/src/main/java/pl/polskalokalnie/auth/dto/AccountRequests.java b/backend/src/main/java/pl/polskalokalnie/auth/dto/AccountRequests.java index b3536a6..28228bd 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/dto/AccountRequests.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/dto/AccountRequests.java @@ -29,6 +29,10 @@ public final class AccountRequests { ) { } + /** Sprawdzenie dotychczasowego hasla przed odblokowaniem pol nowego hasla. */ + public record VerifyPasswordRequest(@NotBlank String password) { + } + /** Usuniecie wlasnego konta - potwierdzane haslem, zeby przejety token nie wystarczyl. */ public record DeleteAccountRequest(@NotBlank String password) { } diff --git a/backend/src/main/java/pl/polskalokalnie/auth/dto/UserResponse.java b/backend/src/main/java/pl/polskalokalnie/auth/dto/UserResponse.java index f335962..4603198 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/dto/UserResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/dto/UserResponse.java @@ -27,9 +27,16 @@ public record UserResponse( boolean phoneVerified, boolean blocked, int promotionCredits, - Instant createdAt + Instant createdAt, + // Zdjecie profilowe z ustawien konta (data URL) albo null - wtedy interfejs rysuje inicjaly. + String avatarImage ) { + /** Wariant bez zdjecia - dla miejsc, ktore go nie pokazuja (np. lista kont w panelu admina). */ public static UserResponse from(AppUser user) { + return from(user, null); + } + + public static UserResponse from(AppUser user, String avatarImage) { return new UserResponse( user.getId(), user.getEmail(), @@ -48,7 +55,8 @@ public record UserResponse( user.isPhoneVerified(), user.isBlocked(), user.getPromotionCredits(), - user.getCreatedAt() + user.getCreatedAt(), + avatarImage ); } } diff --git a/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java b/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java index 012b31f..d367707 100644 --- a/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java +++ b/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java @@ -84,6 +84,26 @@ public class SchemaFixer { "ALTER TABLE IF EXISTS user_reports ALTER COLUMN reported_user_blocked SET DEFAULT FALSE" ); + // Widocznosc profilu ma juz tylko dwie wartosci - wariant "tylko kontakty" zostal usuniety. + // Konta, ktore go wybraly, chcialy ograniczyc widocznosc, wiec ladujemy je na PRIVATE + // (bardziej restrykcyjnej), a nie na PUBLIC. Kolejnosc jest istotna: najpierw dane, + // potem constraint, ktory tych danych juz zabrania. + jdbcTemplate.execute( + "DO $$ " + + "BEGIN " + + " IF to_regclass('public.user_settings') IS NOT NULL THEN " + + " UPDATE user_settings SET profile_visibility = 'PRIVATE' WHERE profile_visibility = 'CONTACTS'; " + + " END IF; " + + "END $$" + ); + jdbcTemplate.execute( + "ALTER TABLE IF EXISTS user_settings DROP CONSTRAINT IF EXISTS user_settings_profile_visibility_check" + ); + jdbcTemplate.execute( + "ALTER TABLE IF EXISTS user_settings ADD CONSTRAINT user_settings_profile_visibility_check " + + "CHECK (profile_visibility IN ('PUBLIC','PRIVATE'))" + ); + // Hibernate (ddl-auto=update) nie aktualizuje CHECK constraintu enuma po dodaniu nowej wartosci. // Odtwarzamy go tak, aby dopuszczal status PAUSED (wstrzymane ogloszenie uzytkownika). jdbcTemplate.execute( diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java index 43c2431..6f4e7a8 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java @@ -49,12 +49,14 @@ public record ListingDetailResponse( // Konto wlasciciela, gdy ogloszenie nalezy do zarejestrowanego uzytkownika - po tym // identyfikatorze frontend otwiera jego profil publiczny. Long ownerId, + // Zdjecie profilowe wlasciciela (data URL) albo null - wtedy karta sprzedajacego rysuje inicjaly. + String ownerAvatar, ListingStatus status, Instant createdAt, Long viewsCount, Instant promotedUntil ) { - public static ListingDetailResponse from(PropertyListing listing, Long ownerId) { + public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar) { return new ListingDetailResponse( listing.getId(), listing.getTitle(), @@ -94,6 +96,7 @@ public record ListingDetailResponse( listing.getVirtualTourUrl(), listing.getOwnerEmail(), ownerId, + ownerAvatar, listing.getStatus(), listing.getCreatedAt(), listing.getViewsCount(), diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java index a3307e4..8d07cbb 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java @@ -1,8 +1,10 @@ package pl.polskalokalnie.listing; +import java.text.Normalizer; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Locale; import java.net.URI; import java.net.URISyntaxException; import org.springframework.http.HttpStatus; @@ -13,6 +15,7 @@ import pl.polskalokalnie.moderation.TextModerationService; import pl.polskalokalnie.notification.NotificationService; import pl.polskalokalnie.user.AppUser; import pl.polskalokalnie.user.UserRepository; +import pl.polskalokalnie.user.UserSettingsService; @Service public class ListingService { @@ -23,21 +26,50 @@ public class ListingService { private final TextModerationService textModerationService; private final NotificationService notificationService; private final UserRepository userRepository; + private final UserSettingsService userSettingsService; public ListingService(ListingRepository listingRepository, TextModerationService textModerationService, - NotificationService notificationService, UserRepository userRepository) { + NotificationService notificationService, UserRepository userRepository, + UserSettingsService userSettingsService) { this.listingRepository = listingRepository; this.textModerationService = textModerationService; this.notificationService = notificationService; this.userRepository = userRepository; + this.userSettingsService = userSettingsService; } // Konto wlasciciela ogloszenia - null, gdy ogloszenie nie ma odpowiednika wsrod uzytkownikow. - private Long ownerIdOf(PropertyListing listing) { + private AppUser ownerOf(PropertyListing listing) { if (listing.getOwnerEmail() == null || listing.getOwnerEmail().isBlank()) { return null; } - return userRepository.findByEmailIgnoreCase(listing.getOwnerEmail()).map(AppUser::getId).orElse(null); + return userRepository.findByEmailIgnoreCase(listing.getOwnerEmail()).orElse(null); + } + + /** + * Szczegoly ogloszenia razem z danymi wlasciciela: identyfikatorem profilu i jego zdjeciem. + * Ogloszenia demonstracyjne nie maja konta w serwisie - wtedy oba pola sa puste. + */ + private ListingDetailResponse detailOf(PropertyListing listing) { + AppUser owner = ownerOf(listing); + if (owner == null) { + return ListingDetailResponse.from(listing, null, null); + } + return ListingDetailResponse.from(listing, owner.getId(), userSettingsService.avatarFor(owner.getId())); + } + + /** + * Nazwa miasta bez polskich znakow i wielkosci liter - "lodz" ma znalezc "Łódź". Litera "ł" + * wymaga osobnej podmiany, bo Normalizer jej nie rozklada (to samodzielny znak, a nie "l" + * ze znakiem diakrytycznym). + */ + private static String normalizeCity(String value) { + if (value == null) { + return ""; + } + String lower = value.trim().toLowerCase(Locale.ROOT).replace("ł", "l"); + return Normalizer.normalize(lower, Normalizer.Form.NFD) + .replaceAll("\\p{InCombiningDiacriticalMarks}+", ""); } // Promowane (aktywne wyroznienie) na gorze, potem najnowsze wg daty dodania. @@ -49,7 +81,7 @@ public class ListingService { public List search(String city, OfferType offerType, PropertyType propertyType) { return listingRepository.findAll().stream() .filter(listing -> listing.getStatus() == ListingStatus.APPROVED) - .filter(listing -> city == null || listing.getCity().equalsIgnoreCase(city.trim())) + .filter(listing -> city == null || normalizeCity(listing.getCity()).equals(normalizeCity(city))) .filter(listing -> offerType == null || listing.getOfferType() == offerType) .filter(listing -> propertyType == null || listing.getPropertyType() == propertyType) .sorted(promotedFirst()) @@ -78,7 +110,7 @@ public class ListingService { listing = listingRepository.save(listing); } - return ListingDetailResponse.from(listing, ownerIdOf(listing)); + return detailOf(listing); } public List findMine(String ownerEmail) { @@ -134,7 +166,7 @@ public class ListingService { listing.setStatus(ListingStatus.PENDING); PropertyListing saved = listingRepository.save(listing); - return ListingDetailResponse.from(saved, ownerIdOf(saved)); + return detailOf(saved); } // Edycja wlasnego ogloszenia. Waliduje tresc filtrem, ale NIE zmienia statusu - @@ -153,7 +185,7 @@ public class ListingService { // ownerEmail i status pozostaja bez zmian. PropertyListing saved = listingRepository.save(listing); - return ListingDetailResponse.from(saved, ownerIdOf(saved)); + return detailOf(saved); } // Wspolne mapowanie pol requestu na encje (uzywane przez create i updateOwn). diff --git a/backend/src/main/java/pl/polskalokalnie/report/ListingReportResponse.java b/backend/src/main/java/pl/polskalokalnie/report/ListingReportResponse.java index f47129d..551a2cd 100644 --- a/backend/src/main/java/pl/polskalokalnie/report/ListingReportResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/report/ListingReportResponse.java @@ -2,6 +2,7 @@ package pl.polskalokalnie.report; import java.time.Instant; import java.util.List; +import pl.polskalokalnie.user.AppUser; public record ListingReportResponse( Long id, @@ -23,7 +24,12 @@ public record ListingReportResponse( String resolvedByEmail, String resolutionNote ) { - public static ListingReportResponse from(ListingReport report) { + /** + * Nazwe zglaszajacego bierzemy z jego konta, jesli nadal istnieje - po zmianie imienia + * i nazwiska panel administratora ma pokazywac aktualna wartosc, a nie te sprzed zmiany. + * Nazwa zapisana w zgloszeniu zostaje zapasem dla kont, ktorych juz nie ma. + */ + public static ListingReportResponse from(ListingReport report, AppUser reporterNow) { return new ListingReportResponse( report.getId(), report.getListingId(), @@ -36,7 +42,9 @@ public record ListingReportResponse( report.getAttachments().stream().map(ListingReportAttachment::getFileName).toList(), report.getAttachments().stream().map(ListingReportAttachmentResponse::from).toList(), report.getReporterEmail(), - report.getReporterName(), + reporterNow != null && reporterNow.getFullName() != null && !reporterNow.getFullName().isBlank() + ? reporterNow.getFullName() + : report.getReporterName(), report.getStatus(), report.isListingDeleted(), report.getCreatedAt(), diff --git a/backend/src/main/java/pl/polskalokalnie/report/ListingReportService.java b/backend/src/main/java/pl/polskalokalnie/report/ListingReportService.java index 85d4d09..b328d63 100644 --- a/backend/src/main/java/pl/polskalokalnie/report/ListingReportService.java +++ b/backend/src/main/java/pl/polskalokalnie/report/ListingReportService.java @@ -46,12 +46,20 @@ public class ListingReportService { report.setReporterName(userRepository.findByEmailIgnoreCase(reporterEmail).map(AppUser::getFullName).orElse(null)); report.setStatus(ListingReportStatus.OPEN); - return ListingReportResponse.from(listingReportRepository.save(report)); + return ListingReportResponse.from(listingReportRepository.save(report), reporterOf(report)); + } + + // Konto zglaszajacego, jesli nadal istnieje - stad bierze sie aktualna nazwa w panelu admina. + private AppUser reporterOf(ListingReport report) { + if (report.getReporterEmail() == null || report.getReporterEmail().isBlank()) { + return null; + } + return userRepository.findByEmailIgnoreCase(report.getReporterEmail()).orElse(null); } public List findAllForAdmin() { return listingReportRepository.findAllByOrderByCreatedAtDesc().stream() - .map(ListingReportResponse::from) + .map(report -> ListingReportResponse.from(report, reporterOf(report))) .toList(); } @@ -62,7 +70,7 @@ public class ListingReportService { report.setResolvedAt(Instant.now()); report.setResolvedByEmail(adminEmail); report.setResolutionNote(trimToNull(note)); - return ListingReportResponse.from(listingReportRepository.save(report)); + return ListingReportResponse.from(listingReportRepository.save(report), reporterOf(report)); } @Transactional @@ -81,7 +89,7 @@ public class ListingReportService { if (report.getResolutionNote() == null || report.getResolutionNote().isBlank()) { report.setResolutionNote("Ogłoszenie usunięte przez administratora po zgłoszeniu."); } - return ListingReportResponse.from(listingReportRepository.save(report)); + return ListingReportResponse.from(listingReportRepository.save(report), reporterOf(report)); } private ListingReport requireReport(Long id) { diff --git a/backend/src/main/java/pl/polskalokalnie/report/UserReportResponse.java b/backend/src/main/java/pl/polskalokalnie/report/UserReportResponse.java index 1e397c8..eabc7cb 100644 --- a/backend/src/main/java/pl/polskalokalnie/report/UserReportResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/report/UserReportResponse.java @@ -2,6 +2,7 @@ package pl.polskalokalnie.report; import java.time.Instant; import java.util.List; +import pl.polskalokalnie.user.AppUser; public record UserReportResponse( Long id, @@ -27,10 +28,15 @@ public record UserReportResponse( String resolvedByEmail, String resolutionNote ) { - public static UserReportResponse from(UserReport report, boolean reportedUserExists) { + /** + * Nazwy bierzemy z kont, jesli te nadal istnieja - uzytkownik moze zmienic imie i nazwisko + * po zlozeniu zgloszenia, a panel administratora ma pokazywac stan zgodny z kontem. Nazwa + * zapisana w zgloszeniu zostaje jako zapas dla kont, ktorych juz nie ma. + */ + public static UserReportResponse from(UserReport report, AppUser reportedNow, AppUser reporterNow) { return new UserReportResponse( report.getId(), - report.getReportedName(), + currentName(reportedNow, report.getReportedName()), report.getReportedEmail(), report.getConversationId(), report.getListingId(), @@ -48,14 +54,21 @@ public record UserReportResponse( message.isHasPhoto())) .toList(), report.getReporterEmail(), - report.getReporterName(), + currentName(reporterNow, report.getReporterName()), report.getStatus(), report.isReportedUserDeleted(), - reportedUserExists, + reportedNow != null, report.getCreatedAt(), report.getResolvedAt(), report.getResolvedByEmail(), report.getResolutionNote() ); } + + private static String currentName(AppUser account, String storedName) { + if (account == null || account.getFullName() == null || account.getFullName().isBlank()) { + return storedName; + } + return account.getFullName(); + } } diff --git a/backend/src/main/java/pl/polskalokalnie/report/UserReportService.java b/backend/src/main/java/pl/polskalokalnie/report/UserReportService.java index 50b217d..0fe0337 100644 --- a/backend/src/main/java/pl/polskalokalnie/report/UserReportService.java +++ b/backend/src/main/java/pl/polskalokalnie/report/UserReportService.java @@ -51,12 +51,12 @@ public class UserReportService { } UserReport saved = userReportRepository.save(report); - return UserReportResponse.from(saved, reported != null); + return UserReportResponse.from(saved, reported, findReported(saved.getReporterEmail())); } public List findAllForAdmin() { return userReportRepository.findAllByOrderByCreatedAtDesc().stream() - .map(report -> UserReportResponse.from(report, findReported(report.getReportedEmail()) != null)) + .map(report -> UserReportResponse.from(report, findReported(report.getReportedEmail()), findReported(report.getReporterEmail()))) .toList(); } @@ -67,7 +67,7 @@ public class UserReportService { report.setResolvedAt(Instant.now()); report.setResolvedByEmail(adminEmail); report.setResolutionNote(ReportAttachments.trimToNull(note)); - return UserReportResponse.from(userReportRepository.save(report), findReported(report.getReportedEmail()) != null); + return UserReportResponse.from(userReportRepository.save(report), findReported(report.getReportedEmail()), findReported(report.getReporterEmail())); } /** Usuniecie konta zgloszonego uzytkownika wraz z zamknieciem zgloszenia. */ @@ -92,7 +92,7 @@ public class UserReportService { if (report.getResolutionNote() == null || report.getResolutionNote().isBlank()) { report.setResolutionNote("Konto usunięte przez administratora po zgłoszeniu."); } - return UserReportResponse.from(userReportRepository.save(report), false); + return UserReportResponse.from(userReportRepository.save(report), null, findReported(report.getReporterEmail())); } // Zapisujemy koncowke rozmowy - to ostatnie wiadomosci sa istotne dla zgloszenia. diff --git a/backend/src/main/java/pl/polskalokalnie/user/AppUser.java b/backend/src/main/java/pl/polskalokalnie/user/AppUser.java index 8d7bcfb..7e71b40 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/AppUser.java +++ b/backend/src/main/java/pl/polskalokalnie/user/AppUser.java @@ -91,6 +91,10 @@ public class AppUser { @Column(nullable = false, updatable = false) private Instant createdAt; + // Kiedy uzytkownik ostatnio sam zmienil haslo w ustawieniach konta. Na tej podstawie liczymy + // odstep miedzy zmianami; null oznacza, ze haslo nie bylo zmieniane od zalozenia konta. + private Instant passwordChangedAt; + @PrePersist void onCreate() { if (createdAt == null) { @@ -241,4 +245,12 @@ public class AppUser { public Instant getCreatedAt() { return createdAt; } + + public Instant getPasswordChangedAt() { + return passwordChangedAt; + } + + public void setPasswordChangedAt(Instant passwordChangedAt) { + this.passwordChangedAt = passwordChangedAt; + } } diff --git a/backend/src/main/java/pl/polskalokalnie/user/ProfileVisibility.java b/backend/src/main/java/pl/polskalokalnie/user/ProfileVisibility.java index f97d249..7a615f1 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/ProfileVisibility.java +++ b/backend/src/main/java/pl/polskalokalnie/user/ProfileVisibility.java @@ -1,8 +1,11 @@ package pl.polskalokalnie.user; -/** Kto widzi profil publiczny uzytkownika. */ +/** + * Kto widzi profil publiczny uzytkownika. Domyslnie PUBLIC - profil zalozony dzis jest widoczny, + * dopoki wlasciciel sam tego nie zmieni. PRIVATE ukrywa przed innymi wszystko poza informacja, + * ze profil jest prywatny; wlasciciel widzi swoj profil zawsze. + */ public enum ProfileVisibility { PUBLIC, - CONTACTS, PRIVATE } diff --git a/backend/src/main/java/pl/polskalokalnie/user/PublicProfileController.java b/backend/src/main/java/pl/polskalokalnie/user/PublicProfileController.java index 0c98a23..b3ed0e0 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/PublicProfileController.java +++ b/backend/src/main/java/pl/polskalokalnie/user/PublicProfileController.java @@ -2,6 +2,7 @@ package pl.polskalokalnie.user; import java.util.List; import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -31,32 +32,51 @@ public class PublicProfileController { } @GetMapping("/{id}/profile") - public PublicProfileResponse profile(@PathVariable Long id) { + public PublicProfileResponse profile(@PathVariable Long id, Authentication authentication) { return toResponse(userRepository.findById(id) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono takiego użytkownika"))); + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono takiego użytkownika")), + authentication); } /** Ten sam profil pod adresem z nazwa uzytkownika - polskalokalnie.pl/u/{nick}. */ @GetMapping("/by-nick/{nick}/profile") - public PublicProfileResponse profileByNick(@PathVariable String nick) { + public PublicProfileResponse profileByNick(@PathVariable String nick, Authentication authentication) { return toResponse(userRepository.findByNickIgnoreCase(nick) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono takiego użytkownika"))); + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono takiego użytkownika")), + authentication); } - private PublicProfileResponse toResponse(AppUser user) { + /** + * Profil prywatny widzi wylacznie jego wlasciciel. Dla kazdego innego konta zwracamy sama + * informacje, ze profil jest prywatny - decyzje o ukryciu podejmuje serwer, bo ukrywanie + * danych dopiero w interfejsie nie chroni przed odczytem wprost z API. + */ + private boolean isHiddenFrom(AppUser owner, UserSettings settings, Authentication authentication) { + if (settings == null || settings.getProfileVisibility() != ProfileVisibility.PRIVATE) { + return false; + } + String viewerEmail = authentication == null ? null : authentication.getName(); + return viewerEmail == null || owner.getEmail() == null || !viewerEmail.equalsIgnoreCase(owner.getEmail()); + } + + private PublicProfileResponse toResponse(AppUser user, Authentication authentication) { + // Opis, zdjecie i widocznosc uzytkownik ustawia w /konto/ustawienia; brak wiersza = ustawienia domyslne. + UserSettings settings = userSettingsRepository.findByUserId(user.getId()).orElse(null); + if (isHiddenFrom(user, settings, authentication)) { + return PublicProfileResponse.privateFor(user); + } + List listings = user.getEmail() == null ? List.of() : listingRepository.findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(user.getEmail(), ListingStatus.APPROVED); - // Opis i zdjecie profilowe uzytkownik ustawia w /konto/ustawienia; brak wiersza = nic nie ustawil. - UserSettings settings = userSettingsRepository.findByUserId(user.getId()).orElse(null); - return new PublicProfileResponse( user.getId(), user.getFullName(), user.getNick(), settings == null ? null : settings.getBio(), settings == null ? null : settings.getAvatarImage(), + settings == null ? null : settings.getCoverImage(), user.getAccountType(), user.isVerified(), user.getCreatedAt(), @@ -75,7 +95,8 @@ public class PublicProfileController { listing.getBuildingFloors(), listing.getCoverPhoto(), listing.getCreatedAt())) - .toList() + .toList(), + false ); } } diff --git a/backend/src/main/java/pl/polskalokalnie/user/PublicProfileResponse.java b/backend/src/main/java/pl/polskalokalnie/user/PublicProfileResponse.java index 6063a6d..4dd7003 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/PublicProfileResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/user/PublicProfileResponse.java @@ -15,12 +15,38 @@ public record PublicProfileResponse( // Krotki opis "o mnie" z ustawien konta - pole opcjonalne, moze byc null. String bio, String avatarImage, + // Zdjecie w tle profilu z ustawien konta - widzi je kazdy, kto oglada profil publiczny. + String coverImage, AccountType accountType, boolean verified, Instant memberSince, int listingsCount, - List listings + List listings, + // Profil ustawiony jako prywatny, ogladany przez kogos innego niz wlasciciel. Pozostale pola + // sa wtedy puste - interfejs pokazuje sama informacje, ze profil jest prywatny. + boolean privateProfile ) { + /** + * Odpowiedz dla profilu prywatnego. Zostaje tylko tozsamosc konta, zeby dalo sie napisac + * "profil uzytkownika X jest prywatny" - bez ogloszen, opisu, zdjecia i daty dolaczenia. + */ + public static PublicProfileResponse privateFor(AppUser user) { + return new PublicProfileResponse( + user.getId(), + user.getFullName(), + user.getNick(), + null, + null, + null, + null, + false, + null, + 0, + List.of(), + true + ); + } + public record PublicProfileListing( Long id, String title, diff --git a/backend/src/main/java/pl/polskalokalnie/user/SearchPreferencesResponse.java b/backend/src/main/java/pl/polskalokalnie/user/SearchPreferencesResponse.java new file mode 100644 index 0000000..0bb1e35 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/SearchPreferencesResponse.java @@ -0,0 +1,28 @@ +package pl.polskalokalnie.user; + +/** + * Same preferencje wyszukiwania z ustawien konta - bez opisu i zdjec, ktore niesie pelna + * odpowiedz /api/me/settings. Wyszukiwarka pobiera to przy kazdym wejsciu do aplikacji, + * wiec odpowiedz musi byc lekka; zdjecie w tle potrafi wazyc kilkaset kilobajtow. + */ +public record SearchPreferencesResponse( + String locations, + String propertyType, + Integer budgetMax, + Integer areaMin, + Integer areaMax, + Integer roomsMin, + Integer roomsMax +) { + public static SearchPreferencesResponse from(UserSettings settings) { + return new SearchPreferencesResponse( + settings.getSearchLocations(), + settings.getSearchPropertyType(), + settings.getSearchBudgetMax(), + settings.getSearchAreaMin(), + settings.getSearchAreaMax(), + settings.getSearchRoomsMin(), + settings.getSearchRoomsMax() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/UserSettingsController.java b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsController.java index 5558b76..717c8dc 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/UserSettingsController.java +++ b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsController.java @@ -35,6 +35,16 @@ public class UserSettingsController { return service.update(currentUserId(authentication), request); } + /** + * Preferencje wyszukiwania w osobnej, lekkiej odpowiedzi. Wyszukiwarka pobiera je przy wejsciu + * do aplikacji, a pelne ustawienia niosa zdjecie profilowe i zdjecie w tle - kilkaset kilobajtow, + * ktorych do wypelnienia filtrow nie potrzeba. + */ + @GetMapping("/search") + public SearchPreferencesResponse searchPreferences(Authentication authentication) { + return service.searchPreferences(currentUserId(authentication)); + } + private Long currentUserId(Authentication authentication) { return userRepository.findByEmailIgnoreCase(authentication.getName()) .map(AppUser::getId) diff --git a/backend/src/main/java/pl/polskalokalnie/user/UserSettingsService.java b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsService.java index fec150b..4db6882 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/UserSettingsService.java +++ b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsService.java @@ -35,6 +35,23 @@ public class UserSettingsService { repository.findByUserId(userId).orElseGet(() -> UserSettings.defaultsFor(userId))); } + /** Same preferencje wyszukiwania - wyszukiwarka nie potrzebuje reszty ustawien. */ + public SearchPreferencesResponse searchPreferences(Long userId) { + return SearchPreferencesResponse.from( + repository.findByUserId(userId).orElseGet(() -> UserSettings.defaultsFor(userId))); + } + + /** + * Samo zdjecie profilowe - do pokazania obok nazwy uzytkownika w naglowku, na profilu i przy + * ogloszeniu. Zwraca null, gdy uzytkownik zadnego nie ustawil; wtedy interfejs rysuje inicjaly. + * Osobna metoda, bo pelne ustawienia niosa jeszcze zdjecie w tle, ktorego te miejsca nie potrzebuja. + */ + public String avatarFor(Long userId) { + return repository.findByUserId(userId) + .map(UserSettings::getAvatarImage) + .orElse(null); + } + /** * Zapis czesciowy: null zostawia dotychczasowa wartosc, pusty tekst ja czysci. Dzieki temu * kazda sekcja formularza moze zapisac wylacznie swoje pola. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 157ac0a..0979c64 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,8 +3,9 @@ import './styles.css'; import { Link, NavLink, Navigate, Outlet, Route, Routes, useLocation, useNavigate, useNavigationType, useParams, useSearchParams } from 'react-router-dom'; import { ROUTES, listingPath, publicProfilePath, publicProfileNickPath, listingEditPath, mapPath, negotiationPath, priceHistoryPath, companiesPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes'; import { ProtectedRoute } from './ProtectedRoute'; +import { Avatar, initialsFrom } from './Avatar'; import { apiFetch, useAuth } from './auth'; -import type { AccountType, AreaUnit, AuthUser, ContactPreference, Currency, PreferredLanguage, ProfileVisibility, RegisterPending, UserSettings, UserSettingsPatch } from './auth'; +import type { AccountType, AreaUnit, AuthUser, ContactPreference, Currency, PreferredLanguage, ProfileVisibility, RegisterPending, SearchPreferences, UserSettings, UserSettingsPatch } from './auth'; import { useNotifications, toDisplayNotifications } from './notifications'; import type { ServerNotification, DisplayNotification } from './notifications'; import { getManualTranslation } from './i18nOverrides'; @@ -121,6 +122,8 @@ export type ApiListingDetail = { virtualTourUrl: string | null; ownerEmail: string | null; ownerId: number | null; + // Zdjecie profilowe wlasciciela - null, gdy go nie ustawil albo ogloszenie nie ma konta w serwisie. + ownerAvatar: string | null; status: ApiListingStatus; createdAt: string; viewsCount?: number; @@ -2395,15 +2398,88 @@ function clampCenter(center: LatLng): LatLng { } function normalizeSearchText(value: string): string { - return value.trim().toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, ''); + return value + .trim() + .toLowerCase() + // "ł" to osobna litera, a nie "l" ze znakiem diakrytycznym - NFD jej nie rozklada, wiec bez + // tej podmiany "Łódź" zostaje jako "łodz" i wpisane "lodz" nigdy w nie nie trafia. + .replace(/ł/g, 'l') + .normalize('NFD') + .replace(/\p{Diacritic}/gu, ''); } +/** + * Odleglosc edycyjna: ile pojedynczych poprawek dzieli dwa napisy. Liczymy dodanie, usuniecie, + * zamiane litery oraz przestawienie dwoch sasiednich liter ("wrocwal" zamiast "wroclaw") - to + * ostatnie jest jedna z najczestszych literowek przy szybkim pisaniu, a zwykly Levenshtein + * liczylby je jako dwie osobne poprawki i taka nazwa nie zmiescilaby sie w tolerancji. + */ +function editDistance(a: string, b: string): number { + if (a === b) { + return 0; + } + const rows: number[][] = [Array.from({ length: b.length + 1 }, (_, index) => index)]; + for (let i = 1; i <= a.length; i += 1) { + const current = [i]; + for (let j = 1; j <= b.length; j += 1) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + let best = Math.min(current[j - 1] + 1, rows[i - 1][j] + 1, rows[i - 1][j - 1] + cost); + if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) { + best = Math.min(best, rows[i - 2][j - 2] + 1); + } + current[j] = best; + } + rows.push(current); + } + return rows[a.length][b.length]; +} + +/** + * Ile literowek wybaczamy przy zapytaniu danej dlugosci. Krotkich nie ruszamy: "gda" pasuje + * do zbyt wielu nazw, zeby jeszcze zgadywac, co uzytkownik mial na mysli. + * + * Progi dobrane pomiarem na liscie 55 polskich miast. Dopuszczenie dwoch poprawek juz przy + * siedmiu znakach zaczyna mylic prawdziwe nazwy: "krakow" trafia w Katowice, a "kielce" + * w Siedlce - a to gorszy blad niz nieznalezienie miasta wpisanego z dwiema literowkami. + * Przy obecnych progach jedyne kolizje to pary nierozroznialne takze dla czlowieka + * (Lublin i Lubin, Wrocław i Włocławek). + */ +function cityTypoTolerance(length: number): number { + if (length < 4) { + return 0; + } + return length < 8 ? 1 : 2; +} + +/** + * Czy nazwa miasta pasuje do tego, co wpisano. Najpierw zwykle dopasowanie poczatku nazwy, + * a gdy ono zawiedzie - z marginesem na literowki ("warszwa" trafia w "Warszawa"). + */ function cityStartsWith(cityName: string, query: string): boolean { const normalizedQuery = normalizeSearchText(query); if (!normalizedQuery) { return true; } - return normalizeSearchText(cityName).startsWith(normalizedQuery); + const normalizedName = normalizeSearchText(cityName); + if (normalizedName.startsWith(normalizedQuery)) { + return true; + } + + const tolerance = cityTypoTolerance(normalizedQuery.length); + if (tolerance === 0) { + return false; + } + + // Porownujemy z poczatkiem nazwy, nie z cala - inaczej "gdansk" przegralby z dluzszymi nazwami. + // Dlugosc okna zmieniamy w zakresie tolerancji, zeby zlapac tez brakujaca albo nadmiarowa litere. + const from = Math.max(1, normalizedQuery.length - tolerance); + const to = Math.min(normalizedName.length, normalizedQuery.length + tolerance); + for (let length = from; length <= to; length += 1) { + if (editDistance(normalizedQuery, normalizedName.slice(0, length)) <= tolerance) { + return true; + } + } + return false; } function findKnownCityKey(name: string): string | null { @@ -2647,16 +2723,8 @@ function getLastName(fullName: string): string { return parts.slice(1).join(' '); } -function getInitials(fullName: string): string { - const parts = fullName.trim().split(/\s+/).filter(Boolean); - if (parts.length === 0) { - return '??'; - } - if (parts.length === 1) { - return parts[0].slice(0, 2).toUpperCase(); - } - return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); -} +// Inicjaly liczy komponent awatara - to ta sama regula w kazdym miejscu aplikacji. +const getInitials = initialsFrom; function contactPreferenceLabel(preference: ContactPreference | null | undefined): string { if (preference === 'EMAIL') { @@ -2682,7 +2750,7 @@ function preferredLanguageLabel(language: PreferredLanguage | null | undefined): } function App() { - const { user, logout } = useAuth(); + const { user, logout, loadSearchPreferences } = useAuth(); const navigate = useNavigate(); const location = useLocation(); const navigationType = useNavigationType(); @@ -2715,6 +2783,23 @@ function App() { const notificationsCenter = useNotifications(); const [savedSearchesState, setSavedSearchesState] = useState([]); + /** + * Preferencje wyszukiwania z ustawien konta wypelniaja filtry raz - po zalogowaniu. Pozniej + * nic ich nie nadpisuje, wiec zmiana filtrow przez uzytkownika jest ostateczna az do wylogowania. + * Gosc nie ma preferencji i widzi puste filtry, tak jak dotad. + */ + const { applyPreferences } = filters; + useEffect(() => { + if (!user) { + return; + } + let cancelled = false; + loadSearchPreferences() + .then((prefs) => { if (!cancelled) { applyPreferences(prefs); } }) + .catch(() => { /* brak preferencji nie moze psuc wyszukiwarki - zostaja puste filtry */ }); + return () => { cancelled = true; }; + }, [user, loadSearchPreferences, applyPreferences]); + // Po udanym logowaniu wracamy tam, gdzie uzytkownik zmierzal (ProtectedRoute zapisuje cel // w location.state.from). Gdy wszedl na logowanie wprost - admin do panelu, uzytkownik do konta. const handleAuthenticated = (loggedUser: AuthUser) => { @@ -3615,7 +3700,7 @@ function App() { } /> } /> } /> - } /> + } /> {/* Bezpieczenstwo i edycja profilu sa teraz sekcjami /konto/ustawienia - stare adresy zostaja jako przekierowania, zeby zapisane linki i zakladki dalej dzialaly. */} } /> @@ -4195,8 +4280,11 @@ function AdminPage() { const [statsLoading, setStatsLoading] = useState(false); const [statsError, setStatsError] = useState(null); - const loadData = useCallback(async () => { - setLoading(true); + // silent = odswiezenie w tle: bez chowania tabel za komunikat "Ladowanie danych". + const loadData = useCallback(async (options?: { silent?: boolean }) => { + if (!options?.silent) { + setLoading(true); + } setError(null); try { const [loadedUsers, loadedListings, loadedReports, loadedUserReports, loadedForbiddenWords] = await Promise.all([ @@ -4222,6 +4310,26 @@ function AdminPage() { loadData(); }, [loadData]); + /** + * Panel pobiera dane przy wejsciu, ale administrator zwykle zostawia go otwartego i przelacza + * sie na inne okna. Gdy wraca do karty, dociagamy dane od nowa - inaczej ogladalby stan sprzed + * zmian, ktore uzytkownicy zrobili w miedzyczasie (np. poprawione imie albo numer telefonu). + * Odswiezamy po powrocie, a nie w kolku - odpowiedz z ogloszeniami wazy kilkaset kilobajtow. + */ + useEffect(() => { + const refreshWhenBack = () => { + if (document.visibilityState === 'visible') { + void loadData({ silent: true }); + } + }; + window.addEventListener('focus', refreshWhenBack); + document.addEventListener('visibilitychange', refreshWhenBack); + return () => { + window.removeEventListener('focus', refreshWhenBack); + document.removeEventListener('visibilitychange', refreshWhenBack); + }; + }, [loadData]); + const runAction = async (actionKey: string, action: () => Promise) => { setBusyId(actionKey); setError(null); @@ -4571,7 +4679,7 @@ function AdminPage() {
System działa poprawnie

Wszystkie usługi działają bez zarzutu.

- +
@@ -4585,7 +4693,7 @@ function AdminPage() {

{activeMeta.title}

Zalogowano jako {user?.fullName || user?.email}. {activeMeta.subtitle}

- @@ -6153,7 +6261,7 @@ function AccountSidebar() { return (