diff --git a/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java b/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java index 2ce04cd..5f5bf71 100644 --- a/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java +++ b/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java @@ -166,6 +166,8 @@ public class AdminController { String temporaryPassword = generateTemporaryPassword(); user.setPasswordHash(passwordEncoder.encode(temporaryPassword)); + // Reset przez administracje (np. przy przejetym koncie) konczy wszystkie sesje uzytkownika. + user.revokeSessions(); userRepository.save(user); notificationService.create(user.getEmail(), "system", "lock", "Hasło zostało zresetowane", diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AuthController.java b/backend/src/main/java/pl/polskalokalnie/auth/AuthController.java index 1ae9ba5..e94a8b0 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/AuthController.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/AuthController.java @@ -112,10 +112,10 @@ public class AuthController { // --- Operacje na wlasnym koncie z /konto/ustawienia --- + // Zmiana hasla konczy wszystkie dotychczasowe sesje - odpowiedz niesie nowy token dla biezacej. @PostMapping("/change-password") - public ResponseEntity changePassword(Authentication authentication, @Valid @RequestBody ChangePasswordRequest request) { - authService.changePassword(authentication.getName(), request.currentPassword(), request.newPassword()); - return ResponseEntity.noContent().build(); + public AuthResponse changePassword(Authentication authentication, @Valid @RequestBody ChangePasswordRequest request) { + return authService.changePassword(authentication.getName(), request.currentPassword(), request.newPassword()); } /** diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java index 7c2dc27..cefefd3 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java @@ -191,6 +191,8 @@ public class AuthService { AppUser user = userRepository.findByEmailIgnoreCase(authToken.getUserEmail()) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje")); user.setPasswordHash(passwordEncoder.encode(newPassword)); + // Reset hasla konczy wszystkie dotychczasowe sesje - takze te przejete przez intruza. + user.revokeSessions(); // Reset hasla potwierdza tez adres e-mail - konto staje sie aktywne. user.setVerified(true); userRepository.save(user); @@ -330,7 +332,7 @@ public class AuthService { * wiec przejeta sesja nie pozwala przejac konta na stale. */ @Transactional - public void changePassword(String email, String currentPassword, String newPassword) { + public AuthResponse changePassword(String email, String currentPassword, String newPassword) { AppUser user = userRepository.findByEmailIgnoreCase(email) .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła")); @@ -357,6 +359,8 @@ public class AuthService { user.setPasswordHash(passwordEncoder.encode(newPassword)); user.setPasswordChangedAt(Instant.now()); + // Inne sesje (np. przejete) koncza sie od razu; biezaca dostaje nowy token w odpowiedzi. + user.revokeSessions(); userRepository.save(user); // Niewykorzystane linki resetu przestaja dzialac po recznej zmianie hasla. @@ -366,6 +370,7 @@ public class AuthService { authTokenRepository.save(old); } }); + return buildAuthResponse(user); } /** diff --git a/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java b/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java index 28a847f..f9c35d2 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java @@ -6,6 +6,7 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.time.Instant; import java.util.List; import org.springframework.lang.NonNull; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -14,14 +15,18 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import pl.polskalokalnie.user.AppUser; +import pl.polskalokalnie.user.UserRepository; @Component public class JwtAuthFilter extends OncePerRequestFilter { private final JwtService jwtService; + private final UserRepository userRepository; - public JwtAuthFilter(JwtService jwtService) { + public JwtAuthFilter(JwtService jwtService, UserRepository userRepository) { this.jwtService = jwtService; + this.userRepository = userRepository; } @Override @@ -34,12 +39,15 @@ public class JwtAuthFilter extends OncePerRequestFilter { if (token != null) { try { Claims claims = jwtService.parse(token); - String email = claims.getSubject(); - String role = claims.get("role", String.class); - var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role)); - var authentication = new UsernamePasswordAuthenticationToken(email, null, authorities); - authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authentication); + // Stan konta bierzemy z bazy, a nie z tokenu: zablokowanie, usuniecie konta, odebranie + // roli administratora i zmiana hasla dzialaja od razu, a nie dopiero po wygasnieciu tokenu. + AppUser user = userRepository.findByEmailIgnoreCase(claims.getSubject()).orElse(null); + if (user != null && isSessionValid(user, claims.getIssuedAt() == null ? null : claims.getIssuedAt().toInstant())) { + var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())); + var authentication = new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } } catch (Exception ex) { // Nieprawidlowy lub wygasly token: zadanie leci dalej jako nieuwierzytelnione. SecurityContextHolder.clearContext(); @@ -48,6 +56,14 @@ public class JwtAuthFilter extends OncePerRequestFilter { filterChain.doFilter(request, response); } + static boolean isSessionValid(AppUser user, Instant issuedAt) { + if (user.isBlocked() || user.getRole() == null) { + return false; + } + Instant validFrom = user.getSessionsValidFrom(); + return validFrom == null || (issuedAt != null && !issuedAt.isBefore(validFrom)); + } + // Token z naglowka Authorization, a dla SSE (EventSource nie ustawia naglowkow) - z parametru access_token. private String resolveToken(HttpServletRequest request) { String header = request.getHeader("Authorization"); diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadService.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadService.java index 9e8cee4..6f27de0 100644 --- a/backend/src/main/java/pl/polskalokalnie/lead/LeadService.java +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadService.java @@ -231,7 +231,11 @@ public class LeadService { lead.setPhone(phone); } } - lead.setName(trimToNull(request.fullName())); + // Formularz jest publiczny i nie potwierdza, ze wysylajacy jest wlascicielem adresu - wpisanie + // cudzego e-maila nie moze nadpisac nazwy istniejacego kontaktu. + if (lead.getId() == null || lead.getName() == null) { + lead.setName(trimToNull(request.fullName())); + } List tags = new ArrayList<>(lead.getTags()); tags.add(ADVERTISING_TAG); @@ -239,9 +243,12 @@ public class LeadService { lead.setTags(cleanTags(tags)); // Zgoda z formularza dotyczy kontaktu z oferta - obejmuje oba kanaly, bo kontakt moze byc - // telefoniczny. Raz cofnietej zgody nie przywracamy tutaj po cichu. - lead.setEmailConsent(consent); - lead.setSmsConsent(consent); + // telefoniczny. Tylko dla NOWEGO kontaktu: formularz jest anonimowy, wiec wpisujac cudzy + // e-mail lub telefon daloby sie komus przywrocic cofnieta zgode (albo ja odebrac). + if (lead.getId() == null) { + lead.setEmailConsent(consent); + lead.setSmsConsent(consent); + } lead.setNotes(appendEnquiryNote(lead.getNotes(), request, lead, email, phone)); leadRepository.save(lead); } diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java index 0e7b177..441e90f 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java @@ -40,6 +40,12 @@ public class ListingController { return listingService.search(city, offerType, propertyType, directOnly); } + // Inne oferty sprzedajacego - publiczne jak same ogloszenia (GET /api/listings/**). + @GetMapping("/{id}/seller-listings") + public List sellerListings(@PathVariable Long id) { + return listingService.sellerListings(id); + } + @GetMapping("/mine") public List mine(Authentication authentication) { return listingService.findMine(authentication.getName()); diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java index 68f1e58..7e38123 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java @@ -72,7 +72,7 @@ public record ListingDetailResponse( private static final double METERS_PER_DEGREE = 111_320d; public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar) { - return from(listing, ownerId, ownerAvatar, null, null, null, null, false); + return from(listing, ownerId, ownerAvatar, null, null, null, null, false, false); } /** @@ -80,10 +80,14 @@ public record ListingDetailResponse( * wlasciciel i administracja), z odpowiedzi znikaja ulica oraz numer budynku, a wspolrzedne sa * przesuwane w obrebie obszaru. Przesuniecie liczymy z identyfikatora ogloszenia, wiec dla danej * oferty jest zawsze takie samo - inaczej kolo skakaloby po mapie przy kazdym odswiezeniu. + * + * Gdy {@code publicView} jest true (oglada ktos inny niz wlasciciel i administracja), znika adres + * e-mail konta wlasciciela - zarowno ownerEmail, jak i contactEmail, ktory formularz wypelnia + * adresem konta. E-mail jest loginem do serwisu, a publiczny kontakt to telefon i wiadomosci. */ public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar, String ownerAccountType, String ownerName, Instant ownerCreatedAt, Instant ownerLastActiveAt, - boolean masked) { + boolean masked, boolean publicView) { String street = masked ? null : listing.getStreet(); String building = masked ? null : listing.getBuilding(); String address = masked ? approximateAddress(listing) : listing.getAddress(); @@ -111,7 +115,7 @@ public record ListingDetailResponse( listing.getOwnership(), listing.getContactName(), listing.getContactPhone(), - listing.getContactEmail(), + publicView ? null : listing.getContactEmail(), listing.getRentExtra(), listing.getAvailableFrom(), listing.getFurnishing(), @@ -126,7 +130,7 @@ public record ListingDetailResponse( List.copyOf(listing.getAmenities()), List.copyOf(listing.getPhotos()), listing.getVirtualTourUrl(), - listing.getOwnerEmail(), + publicView ? null : listing.getOwnerEmail(), ownerId, ownerAvatar, ownerAccountType, diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingFavoriteController.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingFavoriteController.java index 32f5a66..6d7170b 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingFavoriteController.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingFavoriteController.java @@ -61,7 +61,7 @@ public class ListingFavoriteController { // Ogloszenie skasowane albo odrzucone po zapisaniu po prostu znika z listy. return listingRepository.findAllById(listingIds).stream() .filter(listing -> listing.getStatus() == ListingStatus.APPROVED) - .map(listing -> ListingResponse.from(listing, listing.isApproximateLocation())) + .map(ListingResponse::publicFrom) .toList(); } diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java index 2a128ed..fa85958 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java @@ -30,15 +30,20 @@ public record ListingResponse( Instant promotedUntil, Instant createdAt ) { + /** Pelna wersja - dla panelu wlasciciela ("moje ogloszenia") i administracji. */ public static ListingResponse from(PropertyListing listing) { - return from(listing, false); + return build(listing, false, listing.getOwnerEmail()); } /** - * @param masked pokaz sama okolice zamiast adresu - dla ogloszen z ukryta dokladna lokalizacja - * ogladanych spoza panelu wlasciciela i administracji. + * Wersja publiczna (wyszukiwarka, ulubione, inne oferty sprzedajacego): sama okolica zamiast + * adresu dla ogloszen z ukryta lokalizacja i bez e-maila wlasciciela - to login do serwisu. */ - public static ListingResponse from(PropertyListing listing, boolean masked) { + public static ListingResponse publicFrom(PropertyListing listing) { + return build(listing, listing.isApproximateLocation(), null); + } + + private static ListingResponse build(PropertyListing listing, boolean masked, String ownerEmail) { String district = listing.getDistrict(); String address = masked ? (district == null || district.isBlank() ? listing.getCity() : district + ", " + listing.getCity()) @@ -60,7 +65,7 @@ public record ListingResponse( listing.getMarket(), listing.getYearBuilt(), listing.getCoverPhoto(), - listing.getOwnerEmail(), + ownerEmail, listing.getStatus(), listing.getViewsCount(), listing.getPromotedUntil(), diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java index 63cca88..61da0f7 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java @@ -52,21 +52,22 @@ public class ListingService { * Ogloszenia demonstracyjne nie maja konta w serwisie - wtedy oba pola sa puste. */ private ListingDetailResponse detailOf(PropertyListing listing) { - return detailOf(listing, false); + return detailOf(listing, false, false); } /** * @param masked ukryj dokladny adres (ulica, numer, wspolrzedne) - dla ogloszen, ktorych * wlasciciel wybral publikowanie samej przyblizonej lokalizacji. + * @param publicView odpowiedz dla kogos innego niz wlasciciel i administracja - bez adresu e-mail konta. */ - private ListingDetailResponse detailOf(PropertyListing listing, boolean masked) { + private ListingDetailResponse detailOf(PropertyListing listing, boolean masked, boolean publicView) { AppUser owner = ownerOf(listing); if (owner == null) { - return ListingDetailResponse.from(listing, null, null, null, null, null, null, masked); + return ListingDetailResponse.from(listing, null, null, null, null, null, null, masked, publicView); } String ownerAccountType = owner.getAccountType() == null ? null : owner.getAccountType().name(); return ListingDetailResponse.from(listing, owner.getId(), userSettingsService.avatarFor(owner.getId()), - ownerAccountType, owner.getFullName(), owner.getCreatedAt(), owner.getLastActiveAt(), masked); + ownerAccountType, owner.getFullName(), owner.getCreatedAt(), owner.getLastActiveAt(), masked, publicView); } /** @@ -97,7 +98,7 @@ public class ListingService { .filter(listing -> propertyType == null || listing.getPropertyType() == propertyType) .filter(listing -> !directOnly || isDirectOffer(listing)) .sorted(promotedFirst()) - .map(listing -> ListingResponse.from(listing, listing.isApproximateLocation())) + .map(ListingResponse::publicFrom) .toList(); } @@ -134,8 +135,29 @@ public class ListingService { listing = listingRepository.save(listing); } - // Wlasciciel i administracja widza pelny adres - reszta tylko przyblizona lokalizacje. - return detailOf(listing, listing.isApproximateLocation() && !isOwner && !viewerIsAdmin); + // Wlasciciel i administracja widza pelny adres i e-mail konta. Reszta - tylko przyblizona + // lokalizacje i bez e-maila (to login do serwisu; kontakt idzie przez wiadomosci i telefon). + boolean publicView = !isOwner && !viewerIsAdmin; + return detailOf(listing, listing.isApproximateLocation() && publicView, publicView); + } + + /** + * Inne opublikowane oferty wlasciciela danego ogloszenia. Porownanie po e-mailu odbywa sie + * na serwerze, zeby adres wlasciciela nie musial trafiac do przegladarki. + */ + public List sellerListings(Long listingId) { + PropertyListing listing = listingRepository.findById(listingId).orElse(null); + if (listing == null || listing.getStatus() != ListingStatus.APPROVED + || listing.getOwnerEmail() == null || listing.getOwnerEmail().isBlank()) { + return List.of(); + } + return listingRepository + .findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(listing.getOwnerEmail(), ListingStatus.APPROVED) + .stream() + .filter(other -> !other.getId().equals(listingId)) + .limit(6) + .map(ListingResponse::publicFrom) + .toList(); } public List findMine(String ownerEmail) { diff --git a/backend/src/main/java/pl/polskalokalnie/user/AppUser.java b/backend/src/main/java/pl/polskalokalnie/user/AppUser.java index acdd47c..752f48f 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/AppUser.java +++ b/backend/src/main/java/pl/polskalokalnie/user/AppUser.java @@ -95,6 +95,10 @@ public class AppUser { // odstep miedzy zmianami; null oznacza, ze haslo nie bylo zmieniane od zalozenia konta. private Instant passwordChangedAt; + // Tokeny sesji wydane przed tym momentem sa odrzucane - ustawiane przy zmianie i resecie hasla, + // zeby przejeta sesja nie przetrwala zmiany hasla. null = wszystkie niewygasle tokeny wazne. + private Instant sessionsValidFrom; + // Ostatnia aktywnosc uzytkownika (logowanie oraz kazde wejscie do aplikacji odswiezajace /auth/me). // Na karcie sprzedajacego pokazujemy z tego "ostatnio aktywny/a"; null = jeszcze nie odnotowano. private Instant lastActiveAt; @@ -261,6 +265,15 @@ public class AppUser { this.passwordChangedAt = passwordChangedAt; } + public Instant getSessionsValidFrom() { + return sessionsValidFrom; + } + + /** Uniewaznia wszystkie dotychczasowe sesje. JWT ma dokladnosc do sekundy - stad obciecie. */ + public void revokeSessions() { + this.sessionsValidFrom = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS); + } + public Instant getLastActiveAt() { return lastActiveAt; } diff --git a/backend/src/test/java/pl/polskalokalnie/auth/JwtAuthFilterTest.java b/backend/src/test/java/pl/polskalokalnie/auth/JwtAuthFilterTest.java new file mode 100644 index 0000000..b6fa6ca --- /dev/null +++ b/backend/src/test/java/pl/polskalokalnie/auth/JwtAuthFilterTest.java @@ -0,0 +1,42 @@ +package pl.polskalokalnie.auth; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import org.junit.jupiter.api.Test; +import pl.polskalokalnie.user.AppUser; +import pl.polskalokalnie.user.Role; + +class JwtAuthFilterTest { + + private AppUser user() { + AppUser user = new AppUser(); + user.setEmail("user@example.com"); + user.setRole(Role.USER); + return user; + } + + @Test + void activeUserWithFreshTokenIsAccepted() { + assertThat(JwtAuthFilter.isSessionValid(user(), Instant.now())).isTrue(); + } + + @Test + void blockedUserLosesAccessImmediately() { + // Regresja: blokada dzialala tylko przy logowaniu - wydany token dzialal jeszcze do 24 h. + AppUser user = user(); + user.setBlocked(true); + assertThat(JwtAuthFilter.isSessionValid(user, Instant.now())).isFalse(); + } + + @Test + void tokenIssuedBeforePasswordChangeIsRejected() { + AppUser user = user(); + Instant oldToken = Instant.now().minusSeconds(60); + user.revokeSessions(); + + assertThat(JwtAuthFilter.isSessionValid(user, oldToken)).isFalse(); + // Token wydany w tej samej sekundzie co zmiana (nowa sesja z odpowiedzi) jest wazny. + assertThat(JwtAuthFilter.isSessionValid(user, user.getSessionsValidFrom())).isTrue(); + } +} diff --git a/backend/src/test/java/pl/polskalokalnie/lead/AdvertisingEnquiryConsentTest.java b/backend/src/test/java/pl/polskalokalnie/lead/AdvertisingEnquiryConsentTest.java new file mode 100644 index 0000000..7983480 --- /dev/null +++ b/backend/src/test/java/pl/polskalokalnie/lead/AdvertisingEnquiryConsentTest.java @@ -0,0 +1,54 @@ +package pl.polskalokalnie.lead; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class AdvertisingEnquiryConsentTest { + + private final LeadRepository repository = mock(LeadRepository.class); + private final LeadService service = new LeadService(repository); + + private AdvertisingEnquiryRequest enquiry(String name) { + return new AdvertisingEnquiryRequest(name, "Firma", "ofiara@example.com", "500600700", + "Remonty", null, true); + } + + @Test + void anonymousFormDoesNotRestoreWithdrawnConsentOfExistingContact() { + // Regresja: wpisanie cudzego e-maila w publicznym formularzu przywracalo mu zgody marketingowe. + Lead existing = new Lead(); + ReflectionTestUtils.setField(existing, "id", 5L); + existing.setEmail("ofiara@example.com"); + existing.setName("Prawdziwe Nazwisko"); + existing.setEmailConsent(false); + existing.setSmsConsent(false); + when(repository.findFirstByEmailIgnoreCase("ofiara@example.com")).thenReturn(Optional.of(existing)); + when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.registerAdvertisingEnquiry(enquiry("Kliknij")); + + assertThat(existing.isEmailConsent()).isFalse(); + assertThat(existing.isSmsConsent()).isFalse(); + assertThat(existing.getName()).isEqualTo("Prawdziwe Nazwisko"); + } + + @Test + void newContactGetsConsentFromForm() { + when(repository.findFirstByEmailIgnoreCase(any())).thenReturn(Optional.empty()); + when(repository.findFirstByPhone(any())).thenReturn(Optional.empty()); + Lead[] saved = new Lead[1]; + when(repository.save(any())).thenAnswer(inv -> saved[0] = inv.getArgument(0)); + + service.registerAdvertisingEnquiry(enquiry("Jan Kowalski")); + + assertThat(saved[0].isEmailConsent()).isTrue(); + assertThat(saved[0].isSmsConsent()).isTrue(); + assertThat(saved[0].getName()).isEqualTo("Jan Kowalski"); + } +} diff --git a/backend/src/test/java/pl/polskalokalnie/listing/ListingPrivacyTest.java b/backend/src/test/java/pl/polskalokalnie/listing/ListingPrivacyTest.java new file mode 100644 index 0000000..18fa9f6 --- /dev/null +++ b/backend/src/test/java/pl/polskalokalnie/listing/ListingPrivacyTest.java @@ -0,0 +1,35 @@ +package pl.polskalokalnie.listing; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class ListingPrivacyTest { + + private PropertyListing listing() { + PropertyListing listing = new PropertyListing(); + listing.setOwnerEmail("wlasciciel@example.com"); + listing.setContactEmail("wlasciciel@example.com"); + listing.setStatus(ListingStatus.APPROVED); + return listing; + } + + @Test + void publicListDoesNotExposeOwnerEmail() { + // Regresja: anonimowe GET /api/listings zwracalo e-mail (login) kazdego ogloszeniodawcy. + assertThat(ListingResponse.publicFrom(listing()).ownerEmail()).isNull(); + assertThat(ListingResponse.from(listing()).ownerEmail()).isEqualTo("wlasciciel@example.com"); + } + + @Test + void publicDetailHidesAccountEmailButOwnerSeesIt() { + ListingDetailResponse publicView = ListingDetailResponse.from(listing(), 7L, null, null, null, null, null, false, true); + assertThat(publicView.ownerEmail()).isNull(); + assertThat(publicView.contactEmail()).isNull(); + assertThat(publicView.ownerId()).isEqualTo(7L); + + ListingDetailResponse ownerView = ListingDetailResponse.from(listing(), 7L, null, null, null, null, null, false, false); + assertThat(ownerView.ownerEmail()).isEqualTo("wlasciciel@example.com"); + assertThat(ownerView.contactEmail()).isEqualTo("wlasciciel@example.com"); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fdfc050..d6de0c0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27125,25 +27125,20 @@ function ListingDetailPage({ }; }, [listingId]); - // Inne oferty tego samego sprzedajacego (po ownerEmail) - minimalistyczna lista na dole strony. + // Inne oferty tego samego sprzedajacego - minimalistyczna lista na dole strony. Dopasowanie po + // wlascicielu robi serwer, bo e-mail wlasciciela nie jest publiczny. useEffect(() => { - const owner = listing?.ownerEmail?.trim().toLowerCase(); - if (!owner) { + const currentId = listing?.id; + if (currentId == null) { setSellerListings([]); return; } let cancelled = false; - apiFetch('/listings') - .then((all) => { - if (cancelled) { - return; + apiFetch(`/listings/${currentId}/seller-listings`) + .then((items) => { + if (!cancelled) { + setSellerListings(items); } - setSellerListings( - all - .filter((item) => item.id !== listing?.id && item.ownerEmail - && item.ownerEmail.trim().toLowerCase() === owner) - .slice(0, 6), - ); }) .catch(() => { if (!cancelled) { @@ -27153,7 +27148,7 @@ function ListingDetailPage({ return () => { cancelled = true; }; - }, [listing?.ownerEmail, listing?.id]); + }, [listing?.id]); useEffect(() => { if (!listing) { diff --git a/frontend/src/auth.tsx b/frontend/src/auth.tsx index 6cd1547..52f8218 100644 --- a/frontend/src/auth.tsx +++ b/frontend/src/auth.tsx @@ -300,12 +300,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { return updated; }, []); + // Zmiana hasla konczy wszystkie dotychczasowe sesje - serwer oddaje nowy token dla tej. const changePassword = useCallback(async (currentPassword: string, newPassword: string) => { - await apiFetch('/auth/change-password', { + applyAuth(await apiFetch('/auth/change-password', { method: 'POST', body: JSON.stringify({ currentPassword, newPassword }), - }); - }, []); + })); + }, [applyAuth]); // Sprawdzenie dotychczasowego hasla bez zmiany czegokolwiek. Rzuca bledem, gdy haslo jest zle. const verifyPassword = useCallback(async (password: string) => {