Sesje sprawdzane w bazie, e-mail ogloszeniodawcy niepubliczny, zgody z formularza
Deploy Polska Lokalnie / deploy (push) Successful in 12s

- JwtAuthFilter bierze role i stan konta z bazy: blokada, usuniecie konta
  i odebranie roli admina dzialaja od razu. Zmiana i reset hasla koncza
  dotychczasowe sesje (sessionsValidFrom); zmiana hasla zwraca nowy token.
- Publiczne odpowiedzi ogloszen bez ownerEmail/contactEmail; inne oferty
  sprzedajacego z GET /api/listings/{id}/seller-listings.
- Publiczny formularz reklamowy nie zmienia zgod ani nazwy istniejacego leada.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 13:56:36 +02:00
parent 3a555e3b24
commit ac7a3a4efb
16 changed files with 256 additions and 49 deletions
@@ -166,6 +166,8 @@ public class AdminController {
String temporaryPassword = generateTemporaryPassword(); String temporaryPassword = generateTemporaryPassword();
user.setPasswordHash(passwordEncoder.encode(temporaryPassword)); user.setPasswordHash(passwordEncoder.encode(temporaryPassword));
// Reset przez administracje (np. przy przejetym koncie) konczy wszystkie sesje uzytkownika.
user.revokeSessions();
userRepository.save(user); userRepository.save(user);
notificationService.create(user.getEmail(), "system", "lock", notificationService.create(user.getEmail(), "system", "lock",
"Hasło zostało zresetowane", "Hasło zostało zresetowane",
@@ -112,10 +112,10 @@ public class AuthController {
// --- Operacje na wlasnym koncie z /konto/ustawienia --- // --- Operacje na wlasnym koncie z /konto/ustawienia ---
// Zmiana hasla konczy wszystkie dotychczasowe sesje - odpowiedz niesie nowy token dla biezacej.
@PostMapping("/change-password") @PostMapping("/change-password")
public ResponseEntity<Void> changePassword(Authentication authentication, @Valid @RequestBody ChangePasswordRequest request) { public AuthResponse changePassword(Authentication authentication, @Valid @RequestBody ChangePasswordRequest request) {
authService.changePassword(authentication.getName(), request.currentPassword(), request.newPassword()); return authService.changePassword(authentication.getName(), request.currentPassword(), request.newPassword());
return ResponseEntity.noContent().build();
} }
/** /**
@@ -191,6 +191,8 @@ public class AuthService {
AppUser user = userRepository.findByEmailIgnoreCase(authToken.getUserEmail()) AppUser user = userRepository.findByEmailIgnoreCase(authToken.getUserEmail())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
user.setPasswordHash(passwordEncoder.encode(newPassword)); 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. // Reset hasla potwierdza tez adres e-mail - konto staje sie aktywne.
user.setVerified(true); user.setVerified(true);
userRepository.save(user); userRepository.save(user);
@@ -330,7 +332,7 @@ public class AuthService {
* wiec przejeta sesja nie pozwala przejac konta na stale. * wiec przejeta sesja nie pozwala przejac konta na stale.
*/ */
@Transactional @Transactional
public void changePassword(String email, String currentPassword, String newPassword) { public AuthResponse changePassword(String email, String currentPassword, String newPassword) {
AppUser user = userRepository.findByEmailIgnoreCase(email) AppUser user = userRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
@@ -357,6 +359,8 @@ public class AuthService {
user.setPasswordHash(passwordEncoder.encode(newPassword)); user.setPasswordHash(passwordEncoder.encode(newPassword));
user.setPasswordChangedAt(Instant.now()); user.setPasswordChangedAt(Instant.now());
// Inne sesje (np. przejete) koncza sie od razu; biezaca dostaje nowy token w odpowiedzi.
user.revokeSessions();
userRepository.save(user); userRepository.save(user);
// Niewykorzystane linki resetu przestaja dzialac po recznej zmianie hasla. // Niewykorzystane linki resetu przestaja dzialac po recznej zmianie hasla.
@@ -366,6 +370,7 @@ public class AuthService {
authTokenRepository.save(old); authTokenRepository.save(old);
} }
}); });
return buildAuthResponse(user);
} }
/** /**
@@ -6,6 +6,7 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException; import java.io.IOException;
import java.time.Instant;
import java.util.List; import java.util.List;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 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.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
@Component @Component
public class JwtAuthFilter extends OncePerRequestFilter { public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService; private final JwtService jwtService;
private final UserRepository userRepository;
public JwtAuthFilter(JwtService jwtService) { public JwtAuthFilter(JwtService jwtService, UserRepository userRepository) {
this.jwtService = jwtService; this.jwtService = jwtService;
this.userRepository = userRepository;
} }
@Override @Override
@@ -34,12 +39,15 @@ public class JwtAuthFilter extends OncePerRequestFilter {
if (token != null) { if (token != null) {
try { try {
Claims claims = jwtService.parse(token); Claims claims = jwtService.parse(token);
String email = claims.getSubject(); // Stan konta bierzemy z bazy, a nie z tokenu: zablokowanie, usuniecie konta, odebranie
String role = claims.get("role", String.class); // roli administratora i zmiana hasla dzialaja od razu, a nie dopiero po wygasnieciu tokenu.
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role)); AppUser user = userRepository.findByEmailIgnoreCase(claims.getSubject()).orElse(null);
var authentication = new UsernamePasswordAuthenticationToken(email, null, authorities); if (user != null && isSessionValid(user, claims.getIssuedAt() == null ? null : claims.getIssuedAt().toInstant())) {
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name()));
SecurityContextHolder.getContext().setAuthentication(authentication); var authentication = new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception ex) { } catch (Exception ex) {
// Nieprawidlowy lub wygasly token: zadanie leci dalej jako nieuwierzytelnione. // Nieprawidlowy lub wygasly token: zadanie leci dalej jako nieuwierzytelnione.
SecurityContextHolder.clearContext(); SecurityContextHolder.clearContext();
@@ -48,6 +56,14 @@ public class JwtAuthFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response); 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. // Token z naglowka Authorization, a dla SSE (EventSource nie ustawia naglowkow) - z parametru access_token.
private String resolveToken(HttpServletRequest request) { private String resolveToken(HttpServletRequest request) {
String header = request.getHeader("Authorization"); String header = request.getHeader("Authorization");
@@ -231,7 +231,11 @@ public class LeadService {
lead.setPhone(phone); 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<String> tags = new ArrayList<>(lead.getTags()); List<String> tags = new ArrayList<>(lead.getTags());
tags.add(ADVERTISING_TAG); tags.add(ADVERTISING_TAG);
@@ -239,9 +243,12 @@ public class LeadService {
lead.setTags(cleanTags(tags)); lead.setTags(cleanTags(tags));
// Zgoda z formularza dotyczy kontaktu z oferta - obejmuje oba kanaly, bo kontakt moze byc // Zgoda z formularza dotyczy kontaktu z oferta - obejmuje oba kanaly, bo kontakt moze byc
// telefoniczny. Raz cofnietej zgody nie przywracamy tutaj po cichu. // telefoniczny. Tylko dla NOWEGO kontaktu: formularz jest anonimowy, wiec wpisujac cudzy
lead.setEmailConsent(consent); // e-mail lub telefon daloby sie komus przywrocic cofnieta zgode (albo ja odebrac).
lead.setSmsConsent(consent); if (lead.getId() == null) {
lead.setEmailConsent(consent);
lead.setSmsConsent(consent);
}
lead.setNotes(appendEnquiryNote(lead.getNotes(), request, lead, email, phone)); lead.setNotes(appendEnquiryNote(lead.getNotes(), request, lead, email, phone));
leadRepository.save(lead); leadRepository.save(lead);
} }
@@ -40,6 +40,12 @@ public class ListingController {
return listingService.search(city, offerType, propertyType, directOnly); return listingService.search(city, offerType, propertyType, directOnly);
} }
// Inne oferty sprzedajacego - publiczne jak same ogloszenia (GET /api/listings/**).
@GetMapping("/{id}/seller-listings")
public List<ListingResponse> sellerListings(@PathVariable Long id) {
return listingService.sellerListings(id);
}
@GetMapping("/mine") @GetMapping("/mine")
public List<ListingResponse> mine(Authentication authentication) { public List<ListingResponse> mine(Authentication authentication) {
return listingService.findMine(authentication.getName()); return listingService.findMine(authentication.getName());
@@ -72,7 +72,7 @@ public record ListingDetailResponse(
private static final double METERS_PER_DEGREE = 111_320d; private static final double METERS_PER_DEGREE = 111_320d;
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar) { 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 * 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 * 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. * 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, public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar,
String ownerAccountType, String ownerName, Instant ownerCreatedAt, Instant ownerLastActiveAt, String ownerAccountType, String ownerName, Instant ownerCreatedAt, Instant ownerLastActiveAt,
boolean masked) { boolean masked, boolean publicView) {
String street = masked ? null : listing.getStreet(); String street = masked ? null : listing.getStreet();
String building = masked ? null : listing.getBuilding(); String building = masked ? null : listing.getBuilding();
String address = masked ? approximateAddress(listing) : listing.getAddress(); String address = masked ? approximateAddress(listing) : listing.getAddress();
@@ -111,7 +115,7 @@ public record ListingDetailResponse(
listing.getOwnership(), listing.getOwnership(),
listing.getContactName(), listing.getContactName(),
listing.getContactPhone(), listing.getContactPhone(),
listing.getContactEmail(), publicView ? null : listing.getContactEmail(),
listing.getRentExtra(), listing.getRentExtra(),
listing.getAvailableFrom(), listing.getAvailableFrom(),
listing.getFurnishing(), listing.getFurnishing(),
@@ -126,7 +130,7 @@ public record ListingDetailResponse(
List.copyOf(listing.getAmenities()), List.copyOf(listing.getAmenities()),
List.copyOf(listing.getPhotos()), List.copyOf(listing.getPhotos()),
listing.getVirtualTourUrl(), listing.getVirtualTourUrl(),
listing.getOwnerEmail(), publicView ? null : listing.getOwnerEmail(),
ownerId, ownerId,
ownerAvatar, ownerAvatar,
ownerAccountType, ownerAccountType,
@@ -61,7 +61,7 @@ public class ListingFavoriteController {
// Ogloszenie skasowane albo odrzucone po zapisaniu po prostu znika z listy. // Ogloszenie skasowane albo odrzucone po zapisaniu po prostu znika z listy.
return listingRepository.findAllById(listingIds).stream() return listingRepository.findAllById(listingIds).stream()
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED) .filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
.map(listing -> ListingResponse.from(listing, listing.isApproximateLocation())) .map(ListingResponse::publicFrom)
.toList(); .toList();
} }
@@ -30,15 +30,20 @@ public record ListingResponse(
Instant promotedUntil, Instant promotedUntil,
Instant createdAt Instant createdAt
) { ) {
/** Pelna wersja - dla panelu wlasciciela ("moje ogloszenia") i administracji. */
public static ListingResponse from(PropertyListing listing) { 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 * Wersja publiczna (wyszukiwarka, ulubione, inne oferty sprzedajacego): sama okolica zamiast
* ogladanych spoza panelu wlasciciela i administracji. * 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 district = listing.getDistrict();
String address = masked String address = masked
? (district == null || district.isBlank() ? listing.getCity() : district + ", " + listing.getCity()) ? (district == null || district.isBlank() ? listing.getCity() : district + ", " + listing.getCity())
@@ -60,7 +65,7 @@ public record ListingResponse(
listing.getMarket(), listing.getMarket(),
listing.getYearBuilt(), listing.getYearBuilt(),
listing.getCoverPhoto(), listing.getCoverPhoto(),
listing.getOwnerEmail(), ownerEmail,
listing.getStatus(), listing.getStatus(),
listing.getViewsCount(), listing.getViewsCount(),
listing.getPromotedUntil(), listing.getPromotedUntil(),
@@ -52,21 +52,22 @@ public class ListingService {
* Ogloszenia demonstracyjne nie maja konta w serwisie - wtedy oba pola sa puste. * Ogloszenia demonstracyjne nie maja konta w serwisie - wtedy oba pola sa puste.
*/ */
private ListingDetailResponse detailOf(PropertyListing listing) { 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 * @param masked ukryj dokladny adres (ulica, numer, wspolrzedne) - dla ogloszen, ktorych
* wlasciciel wybral publikowanie samej przyblizonej lokalizacji. * 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); AppUser owner = ownerOf(listing);
if (owner == null) { 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(); String ownerAccountType = owner.getAccountType() == null ? null : owner.getAccountType().name();
return ListingDetailResponse.from(listing, owner.getId(), userSettingsService.avatarFor(owner.getId()), 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 -> propertyType == null || listing.getPropertyType() == propertyType)
.filter(listing -> !directOnly || isDirectOffer(listing)) .filter(listing -> !directOnly || isDirectOffer(listing))
.sorted(promotedFirst()) .sorted(promotedFirst())
.map(listing -> ListingResponse.from(listing, listing.isApproximateLocation())) .map(ListingResponse::publicFrom)
.toList(); .toList();
} }
@@ -134,8 +135,29 @@ public class ListingService {
listing = listingRepository.save(listing); listing = listingRepository.save(listing);
} }
// Wlasciciel i administracja widza pelny adres - reszta tylko przyblizona lokalizacje. // Wlasciciel i administracja widza pelny adres i e-mail konta. Reszta - tylko przyblizona
return detailOf(listing, listing.isApproximateLocation() && !isOwner && !viewerIsAdmin); // 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<ListingResponse> 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<ListingResponse> findMine(String ownerEmail) { public List<ListingResponse> findMine(String ownerEmail) {
@@ -95,6 +95,10 @@ public class AppUser {
// odstep miedzy zmianami; null oznacza, ze haslo nie bylo zmieniane od zalozenia konta. // odstep miedzy zmianami; null oznacza, ze haslo nie bylo zmieniane od zalozenia konta.
private Instant passwordChangedAt; 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). // 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. // Na karcie sprzedajacego pokazujemy z tego "ostatnio aktywny/a"; null = jeszcze nie odnotowano.
private Instant lastActiveAt; private Instant lastActiveAt;
@@ -261,6 +265,15 @@ public class AppUser {
this.passwordChangedAt = passwordChangedAt; 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() { public Instant getLastActiveAt() {
return lastActiveAt; return lastActiveAt;
} }
@@ -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();
}
}
@@ -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("<a href=https://evil>Kliknij</a>"));
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");
}
}
@@ -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");
}
}
+9 -14
View File
@@ -27125,25 +27125,20 @@ function ListingDetailPage({
}; };
}, [listingId]); }, [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(() => { useEffect(() => {
const owner = listing?.ownerEmail?.trim().toLowerCase(); const currentId = listing?.id;
if (!owner) { if (currentId == null) {
setSellerListings([]); setSellerListings([]);
return; return;
} }
let cancelled = false; let cancelled = false;
apiFetch<ApiListingSummary[]>('/listings') apiFetch<ApiListingSummary[]>(`/listings/${currentId}/seller-listings`)
.then((all) => { .then((items) => {
if (cancelled) { if (!cancelled) {
return; setSellerListings(items);
} }
setSellerListings(
all
.filter((item) => item.id !== listing?.id && item.ownerEmail
&& item.ownerEmail.trim().toLowerCase() === owner)
.slice(0, 6),
);
}) })
.catch(() => { .catch(() => {
if (!cancelled) { if (!cancelled) {
@@ -27153,7 +27148,7 @@ function ListingDetailPage({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [listing?.ownerEmail, listing?.id]); }, [listing?.id]);
useEffect(() => { useEffect(() => {
if (!listing) { if (!listing) {
+4 -3
View File
@@ -300,12 +300,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return updated; return updated;
}, []); }, []);
// Zmiana hasla konczy wszystkie dotychczasowe sesje - serwer oddaje nowy token dla tej.
const changePassword = useCallback(async (currentPassword: string, newPassword: string) => { const changePassword = useCallback(async (currentPassword: string, newPassword: string) => {
await apiFetch<void>('/auth/change-password', { applyAuth(await apiFetch<AuthResponse>('/auth/change-password', {
method: 'POST', method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }), body: JSON.stringify({ currentPassword, newPassword }),
}); }));
}, []); }, [applyAuth]);
// Sprawdzenie dotychczasowego hasla bez zmiany czegokolwiek. Rzuca bledem, gdy haslo jest zle. // Sprawdzenie dotychczasowego hasla bez zmiany czegokolwiek. Rzuca bledem, gdy haslo jest zle.
const verifyPassword = useCallback(async (password: string) => { const verifyPassword = useCallback(async (password: string) => {