zmiany w ustawieniach
This commit is contained in:
@@ -4,12 +4,16 @@ import jakarta.validation.Valid;
|
|||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import pl.polskalokalnie.auth.dto.AccountRequests.ChangePasswordRequest;
|
||||||
|
import pl.polskalokalnie.auth.dto.AccountRequests.DeleteAccountRequest;
|
||||||
import pl.polskalokalnie.auth.dto.AccountRequests.EmailRequest;
|
import pl.polskalokalnie.auth.dto.AccountRequests.EmailRequest;
|
||||||
import pl.polskalokalnie.auth.dto.AccountRequests.ResetPasswordRequest;
|
import pl.polskalokalnie.auth.dto.AccountRequests.ResetPasswordRequest;
|
||||||
import pl.polskalokalnie.auth.dto.AccountRequests.TokenRequest;
|
import pl.polskalokalnie.auth.dto.AccountRequests.TokenRequest;
|
||||||
@@ -98,4 +102,30 @@ public class AuthController {
|
|||||||
public UserResponse updateMe(Authentication authentication, @Valid @RequestBody UpdateProfileRequest request) {
|
public UserResponse updateMe(Authentication authentication, @Valid @RequestBody UpdateProfileRequest request) {
|
||||||
return authService.updateProfile(authentication.getName(), request);
|
return authService.updateProfile(authentication.getName(), request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propozycja nazwy uzytkownika z imienia i nazwiska. Ustawienia konta pokazuja ja jako gotowa
|
||||||
|
* wartosc - nicka nie wpisuje sie recznie, zeby adres profilu nie stal sie polem do zabawy.
|
||||||
|
*/
|
||||||
|
@GetMapping("/nick-suggestion")
|
||||||
|
public NickSuggestionResponse nickSuggestion(Authentication authentication, @RequestParam(required = false) String fullName) {
|
||||||
|
return new NickSuggestionResponse(authService.suggestNick(authentication.getName(), fullName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record NickSuggestionResponse(String nick) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Operacje na wlasnym koncie z /konto/ustawienia ---
|
||||||
|
|
||||||
|
@PostMapping("/change-password")
|
||||||
|
public ResponseEntity<Void> changePassword(Authentication authentication, @Valid @RequestBody ChangePasswordRequest request) {
|
||||||
|
authService.changePassword(authentication.getName(), request.currentPassword(), request.newPassword());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/me")
|
||||||
|
public ResponseEntity<Void> deleteMe(Authentication authentication, @Valid @RequestBody DeleteAccountRequest request) {
|
||||||
|
authService.deleteOwnAccount(authentication.getName(), request.password());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ import pl.polskalokalnie.user.AppUser;
|
|||||||
import pl.polskalokalnie.user.AuthProvider;
|
import pl.polskalokalnie.user.AuthProvider;
|
||||||
import pl.polskalokalnie.user.BlockedEmailRepository;
|
import pl.polskalokalnie.user.BlockedEmailRepository;
|
||||||
import pl.polskalokalnie.user.ContactPreference;
|
import pl.polskalokalnie.user.ContactPreference;
|
||||||
|
import pl.polskalokalnie.user.NickService;
|
||||||
import pl.polskalokalnie.user.PreferredLanguage;
|
import pl.polskalokalnie.user.PreferredLanguage;
|
||||||
import pl.polskalokalnie.user.Role;
|
import pl.polskalokalnie.user.Role;
|
||||||
import pl.polskalokalnie.user.UserRepository;
|
import pl.polskalokalnie.user.UserRepository;
|
||||||
|
import pl.polskalokalnie.user.UserSettingsService;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class AuthService {
|
public class AuthService {
|
||||||
@@ -46,6 +48,8 @@ public class AuthService {
|
|||||||
private final AuthTokenRepository authTokenRepository;
|
private final AuthTokenRepository authTokenRepository;
|
||||||
private final AccountEmailService accountEmailService;
|
private final AccountEmailService accountEmailService;
|
||||||
private final PhoneVerificationService phoneVerificationService;
|
private final PhoneVerificationService phoneVerificationService;
|
||||||
|
private final NickService nickService;
|
||||||
|
private final UserSettingsService userSettingsService;
|
||||||
|
|
||||||
public AuthService(
|
public AuthService(
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
@@ -56,7 +60,9 @@ public class AuthService {
|
|||||||
LeadSyncService leadSyncService,
|
LeadSyncService leadSyncService,
|
||||||
AuthTokenRepository authTokenRepository,
|
AuthTokenRepository authTokenRepository,
|
||||||
AccountEmailService accountEmailService,
|
AccountEmailService accountEmailService,
|
||||||
PhoneVerificationService phoneVerificationService
|
PhoneVerificationService phoneVerificationService,
|
||||||
|
NickService nickService,
|
||||||
|
UserSettingsService userSettingsService
|
||||||
) {
|
) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.blockedEmailRepository = blockedEmailRepository;
|
this.blockedEmailRepository = blockedEmailRepository;
|
||||||
@@ -67,6 +73,8 @@ public class AuthService {
|
|||||||
this.authTokenRepository = authTokenRepository;
|
this.authTokenRepository = authTokenRepository;
|
||||||
this.accountEmailService = accountEmailService;
|
this.accountEmailService = accountEmailService;
|
||||||
this.phoneVerificationService = phoneVerificationService;
|
this.phoneVerificationService = phoneVerificationService;
|
||||||
|
this.nickService = nickService;
|
||||||
|
this.userSettingsService = userSettingsService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -84,6 +92,9 @@ public class AuthService {
|
|||||||
AppUser user = new AppUser();
|
AppUser user = new AppUser();
|
||||||
user.setEmail(email);
|
user.setEmail(email);
|
||||||
user.setFullName(request.fullName().trim());
|
user.setFullName(request.fullName().trim());
|
||||||
|
// Nick nadaje system z imienia i nazwiska ("Jan Kowalski" -> "jan.kowalski"), a przy kolizji
|
||||||
|
// dokleja numer. Uzytkownik nie wymysla go w formularzu rejestracji.
|
||||||
|
user.setNick(nickService.generateFromName(request.fullName(), email, null));
|
||||||
user.setPasswordHash(passwordEncoder.encode(request.password()));
|
user.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||||
user.setRole(Role.USER);
|
user.setRole(Role.USER);
|
||||||
user.setProvider(AuthProvider.LOCAL);
|
user.setProvider(AuthProvider.LOCAL);
|
||||||
@@ -103,13 +114,15 @@ public class AuthService {
|
|||||||
String token = issueToken(saved.getEmail(), TokenPurpose.ACTIVATION, ACTIVATION_TTL_MINUTES);
|
String token = issueToken(saved.getEmail(), TokenPurpose.ACTIVATION, ACTIVATION_TTL_MINUTES);
|
||||||
accountEmailService.sendActivation(saved.getEmail(), saved.getFullName(), token);
|
accountEmailService.sendActivation(saved.getEmail(), saved.getFullName(), token);
|
||||||
|
|
||||||
// Gdy podano telefon - od razu wysylamy kod SMS do potwierdzenia numeru.
|
// Gdy podano telefon - od razu wysylamy kod SMS do potwierdzenia numeru. Konto jest juz
|
||||||
boolean phoneVerificationRequired = phone != null;
|
// zalozone, wiec awaria bramki nie moze przerwac rejestracji; zglaszamy tylko, ze kodu nie ma
|
||||||
if (phoneVerificationRequired) {
|
// co wpisywac, a uzytkownik potwierdzi numer pozniej w ustawieniach konta.
|
||||||
phoneVerificationService.sendOtp(saved.getEmail(), phone);
|
boolean codeSent = false;
|
||||||
|
if (phone != null) {
|
||||||
|
codeSent = phoneVerificationService.sendOtp(saved.getEmail(), phone).delivered();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new RegisterResponse(saved.getEmail(), phoneVerificationRequired);
|
return new RegisterResponse(saved.getEmail(), codeSent);
|
||||||
}
|
}
|
||||||
|
|
||||||
public AuthResponse login(LoginRequest request) {
|
public AuthResponse login(LoginRequest request) {
|
||||||
@@ -187,6 +200,10 @@ public class AuthService {
|
|||||||
phoneVerificationService.verifyOtp(normalizeEmail(email), code);
|
phoneVerificationService.verifyOtp(normalizeEmail(email), code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recznie zamowiony kod SMS. Tutaj - inaczej niz przy rejestracji - porazka bramki musi dojsc
|
||||||
|
* do uzytkownika, zeby nie czekal na SMS, ktory nigdy nie przyjdzie.
|
||||||
|
*/
|
||||||
public void resendPhoneOtp(String email) {
|
public void resendPhoneOtp(String email) {
|
||||||
String normalized = normalizeEmail(email);
|
String normalized = normalizeEmail(email);
|
||||||
AppUser user = userRepository.findByEmailIgnoreCase(normalized)
|
AppUser user = userRepository.findByEmailIgnoreCase(normalized)
|
||||||
@@ -194,7 +211,17 @@ public class AuthService {
|
|||||||
if (user.getPhone() == null || user.getPhone().isBlank()) {
|
if (user.getPhone() == null || user.getPhone().isBlank()) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto nie ma przypisanego numeru telefonu");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto nie ma przypisanego numeru telefonu");
|
||||||
}
|
}
|
||||||
phoneVerificationService.sendOtp(normalized, user.getPhone());
|
if (user.isPhoneVerified()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.CONFLICT, "Ten numer jest już potwierdzony");
|
||||||
|
}
|
||||||
|
|
||||||
|
PhoneVerificationService.OtpDispatch dispatch = phoneVerificationService.sendOtp(normalized, user.getPhone());
|
||||||
|
switch (dispatch.status()) {
|
||||||
|
case SENT -> { }
|
||||||
|
case GATEWAY_DISABLED -> throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, dispatch.detail());
|
||||||
|
case FAILED -> throw new ResponseStatusException(HttpStatus.BAD_GATEWAY,
|
||||||
|
"Nie udało się wysłać SMS-a: " + (dispatch.detail() == null ? "bramka odrzuciła wysyłkę" : dispatch.detail()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Pomocnicze tokeny ---
|
// --- Pomocnicze tokeny ---
|
||||||
@@ -252,6 +279,7 @@ public class AuthService {
|
|||||||
AppUser created = new AppUser();
|
AppUser created = new AppUser();
|
||||||
created.setEmail(email);
|
created.setEmail(email);
|
||||||
created.setFullName(name);
|
created.setFullName(name);
|
||||||
|
created.setNick(nickService.generateFromName(name, email, null));
|
||||||
created.setRole(Role.USER);
|
created.setRole(Role.USER);
|
||||||
created.setProvider(provider);
|
created.setProvider(provider);
|
||||||
// Logowanie spoleczne nie wymaga rekopisania danych, wiec konto jest od razu zweryfikowane.
|
// Logowanie spoleczne nie wymaga rekopisania danych, wiec konto jest od razu zweryfikowane.
|
||||||
@@ -277,10 +305,20 @@ public class AuthService {
|
|||||||
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"));
|
||||||
|
|
||||||
textModerationService.validateOrThrow(request.fullName(), request.phone(), request.birthDate(), request.address());
|
textModerationService.validateOrThrow(request.fullName(), request.nick(), request.phone(), request.birthDate(), request.address());
|
||||||
|
|
||||||
user.setFullName(request.fullName().trim());
|
user.setFullName(request.fullName().trim());
|
||||||
user.setPhone(request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null);
|
if (request.nick() != null && !request.nick().isBlank()) {
|
||||||
|
user.setNick(nickService.validateForUser(request.nick(), user));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zmiana numeru uniewaznia wczesniejsza weryfikacje SMS - inaczej nowy, niepotwierdzony numer
|
||||||
|
// dziedziczylby znaczek "zweryfikowany" po poprzednim.
|
||||||
|
String phone = request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null;
|
||||||
|
if (!java.util.Objects.equals(phone, user.getPhone())) {
|
||||||
|
user.setPhoneVerified(false);
|
||||||
|
}
|
||||||
|
user.setPhone(phone);
|
||||||
user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null);
|
user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null);
|
||||||
user.setContactPreference(request.contactPreference() != null ? request.contactPreference() : ContactPreference.EMAIL_AND_PHONE);
|
user.setContactPreference(request.contactPreference() != null ? request.contactPreference() : ContactPreference.EMAIL_AND_PHONE);
|
||||||
user.setPreferredLanguage(request.preferredLanguage() != null ? request.preferredLanguage() : PreferredLanguage.PL);
|
user.setPreferredLanguage(request.preferredLanguage() != null ? request.preferredLanguage() : PreferredLanguage.PL);
|
||||||
@@ -299,6 +337,58 @@ public class AuthService {
|
|||||||
return UserResponse.from(saved);
|
return UserResponse.from(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zmiana hasla z poziomu ustawien konta. Wymaga dotychczasowego hasla - sam token nie wystarczy,
|
||||||
|
* wiec przejeta sesja nie pozwala przejac konta na stale.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void changePassword(String email, String currentPassword, String newPassword) {
|
||||||
|
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 (!passwordEncoder.matches(currentPassword, user.getPasswordHash())) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Dotychczasowe hasło jest nieprawidłowe");
|
||||||
|
}
|
||||||
|
if (passwordEncoder.matches(newPassword, user.getPasswordHash())) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nowe hasło musi różnić się od dotychczasowego");
|
||||||
|
}
|
||||||
|
|
||||||
|
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||||
|
userRepository.save(user);
|
||||||
|
|
||||||
|
// Niewykorzystane linki resetu przestaja dzialac po recznej zmianie hasla.
|
||||||
|
authTokenRepository.findByUserEmailAndPurpose(user.getEmail(), TokenPurpose.PASSWORD_RESET).forEach(old -> {
|
||||||
|
if (old.getUsedAt() == null) {
|
||||||
|
old.setUsedAt(Instant.now());
|
||||||
|
authTokenRepository.save(old);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usuniecie wlasnego konta. Konta administratorow zostawiamy - tak samo jak przy usuwaniu
|
||||||
|
* z panelu admina - zeby nie dalo sie zlikwidowac ostatniego dostepu do panelu.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void deleteOwnAccount(String email, String password) {
|
||||||
|
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
|
||||||
|
|
||||||
|
if (user.getRole() == Role.ADMIN) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Konta administratora nie można usunąć z poziomu ustawień");
|
||||||
|
}
|
||||||
|
if (user.getPasswordHash() == null || !passwordEncoder.matches(password, user.getPasswordHash())) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Hasło jest nieprawidłowe");
|
||||||
|
}
|
||||||
|
|
||||||
|
userSettingsService.deleteForUser(user.getId());
|
||||||
|
userRepository.delete(user);
|
||||||
|
}
|
||||||
|
|
||||||
private AuthResponse buildAuthResponse(AppUser user) {
|
private AuthResponse buildAuthResponse(AppUser user) {
|
||||||
return new AuthResponse(jwtService.generateToken(user), UserResponse.from(user));
|
return new AuthResponse(jwtService.generateToken(user), UserResponse.from(user));
|
||||||
}
|
}
|
||||||
@@ -307,6 +397,17 @@ public class AuthService {
|
|||||||
return email.trim().toLowerCase(Locale.ROOT);
|
return email.trim().toLowerCase(Locale.ROOT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propozycja nazwy uzytkownika dla podanego imienia i nazwiska. Uzywana przez ustawienia konta:
|
||||||
|
* nicka nie wpisuje sie recznie, tylko przyjmuje gotowa, wolna propozycje systemu.
|
||||||
|
*/
|
||||||
|
public String suggestNick(String email, String fullName) {
|
||||||
|
AppUser user = userRepository.findByEmailIgnoreCase(normalizeEmail(email))
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
|
||||||
|
String source = fullName != null && !fullName.isBlank() ? fullName : user.getFullName();
|
||||||
|
return nickService.generateFromName(source, user.getEmail(), user);
|
||||||
|
}
|
||||||
|
|
||||||
private String defaultSocialEmail(AuthProvider provider) {
|
private String defaultSocialEmail(AuthProvider provider) {
|
||||||
return switch (provider) {
|
return switch (provider) {
|
||||||
case GOOGLE -> "demo.google@gmail.com";
|
case GOOGLE -> "demo.google@gmail.com";
|
||||||
|
|||||||
@@ -32,10 +32,21 @@ public class PhoneOtp {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private int attempts = 0;
|
private int attempts = 0;
|
||||||
|
|
||||||
|
// Znacznik ostatniej wysylki - pilnuje odstepu miedzy kolejnymi SMS-ami (kazdy kosztuje).
|
||||||
|
private Instant lastSentAt;
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Instant getLastSentAt() {
|
||||||
|
return lastSentAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastSentAt(Instant lastSentAt) {
|
||||||
|
this.lastSentAt = lastSentAt;
|
||||||
|
}
|
||||||
|
|
||||||
public String getUserEmail() {
|
public String getUserEmail() {
|
||||||
return userEmail;
|
return userEmail;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package pl.polskalokalnie.auth;
|
package pl.polskalokalnie.auth;
|
||||||
|
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -26,6 +27,7 @@ public class PhoneVerificationService {
|
|||||||
private static final SecureRandom RANDOM = new SecureRandom();
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
private static final int MAX_ATTEMPTS = 5;
|
private static final int MAX_ATTEMPTS = 5;
|
||||||
private static final int TTL_MINUTES = 10;
|
private static final int TTL_MINUTES = 10;
|
||||||
|
private static final int RESEND_COOLDOWN_SECONDS = 60;
|
||||||
|
|
||||||
private final PhoneOtpRepository otpRepository;
|
private final PhoneOtpRepository otpRepository;
|
||||||
private final SmsSender smsSender;
|
private final SmsSender smsSender;
|
||||||
@@ -42,31 +44,74 @@ public class PhoneVerificationService {
|
|||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wysyla kod SMS i mowi, co sie z nim naprawde stalo. Wolajacy decyduje, czy porazka ma
|
||||||
|
* przerwac operacje (recznie klikniete "wyslij kod") czy tylko ja oznaczyc (rejestracja,
|
||||||
|
* gdzie konto jest juz zalozone i nie wolno go stracic przez awarie bramki).
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void sendOtp(String email, String phone) {
|
public OtpDispatch sendOtp(String email, String phone) {
|
||||||
if (phone == null || phone.isBlank()) {
|
if (phone == null || phone.isBlank()) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak numeru telefonu");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak numeru telefonu");
|
||||||
}
|
}
|
||||||
String code = String.format("%06d", RANDOM.nextInt(1_000_000));
|
|
||||||
|
|
||||||
PhoneOtp otp = otpRepository.findByUserEmail(email).orElseGet(PhoneOtp::new);
|
PhoneOtp otp = otpRepository.findByUserEmail(email).orElseGet(PhoneOtp::new);
|
||||||
|
|
||||||
|
// Kazdy SMS kosztuje, a endpoint wysylki jest publiczny - trzymamy odstep miedzy wysylkami.
|
||||||
|
Instant lastSentAt = otp.getLastSentAt();
|
||||||
|
if (lastSentAt != null) {
|
||||||
|
long secondsSince = Duration.between(lastSentAt, Instant.now()).getSeconds();
|
||||||
|
if (secondsSince < RESEND_COOLDOWN_SECONDS) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
"Kod został już wysłany. Kolejny możesz zamówić za " + (RESEND_COOLDOWN_SECONDS - secondsSince) + " s");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String code = String.format("%06d", RANDOM.nextInt(1_000_000));
|
||||||
otp.setUserEmail(email);
|
otp.setUserEmail(email);
|
||||||
otp.setPhone(phone.trim());
|
otp.setPhone(phone.trim());
|
||||||
otp.setCode(code);
|
otp.setCode(code);
|
||||||
otp.setExpiresAt(Instant.now().plus(TTL_MINUTES, ChronoUnit.MINUTES));
|
otp.setExpiresAt(Instant.now().plus(TTL_MINUTES, ChronoUnit.MINUTES));
|
||||||
otp.setAttempts(0);
|
otp.setAttempts(0);
|
||||||
otpRepository.save(otp);
|
|
||||||
|
|
||||||
SmsGatewayConfig sms = mailConfigService.getSms();
|
SmsGatewayConfig sms = mailConfigService.getSms();
|
||||||
boolean configured = sms.isEnabled() && sms.getApiKey() != null && !sms.getApiKey().isBlank();
|
boolean configured = sms.isEnabled() && sms.getApiKey() != null && !sms.getApiKey().isBlank()
|
||||||
String message = "Polska Lokalnie: Twoj kod weryfikacyjny to " + code + ". Wazny 10 minut.";
|
&& sms.getEndpointUrl() != null && !sms.getEndpointUrl().isBlank();
|
||||||
if (!configured) {
|
if (!configured) {
|
||||||
|
// Tryb deweloperski: bez bramki kod trafia do logu, ale nie udajemy, ze SMS poszedl.
|
||||||
|
// Nie ustawiamy lastSentAt - skoro nic nie wyszlo, nie ma za co karac odstepem.
|
||||||
|
otpRepository.save(otp);
|
||||||
log.info("[SMS-OTP] Bramka SMS nieskonfigurowana. Kod dla {} ({}): {}", email, phone, code);
|
log.info("[SMS-OTP] Bramka SMS nieskonfigurowana. Kod dla {} ({}): {}", email, phone, code);
|
||||||
return;
|
return new OtpDispatch(OtpStatus.GATEWAY_DISABLED,
|
||||||
|
"Bramka SMS nie jest włączona - skonfiguruj ją w panelu administratora (Konfiguracja SMS).");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Odstep liczymy od momentu, w ktorym naprawde odpytalismy bramke.
|
||||||
|
otp.setLastSentAt(Instant.now());
|
||||||
|
otpRepository.save(otp);
|
||||||
|
|
||||||
|
String message = "Polska Lokalnie: Twoj kod weryfikacyjny to " + code + ". Wazny 10 minut.";
|
||||||
var result = smsSender.send(sms, phone.trim(), message);
|
var result = smsSender.send(sms, phone.trim(), message);
|
||||||
if (!result.ok()) {
|
if (!result.ok()) {
|
||||||
log.warn("[SMS-OTP] Nie udalo sie wyslac kodu do {} ({}). Kod: {}", email, phone, code);
|
// Bez logowania kodu - przy dzialajacej bramce nie ma powodu, zeby trzymac go w logach.
|
||||||
|
log.warn("[SMS-OTP] Bramka odrzucila wysylke do {}: {}", email, result.error());
|
||||||
|
return new OtpDispatch(OtpStatus.FAILED, result.error());
|
||||||
|
}
|
||||||
|
return new OtpDispatch(OtpStatus.SENT, result.providerMessageId());
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum OtpStatus {
|
||||||
|
/** SMS przyjety przez bramke. */
|
||||||
|
SENT,
|
||||||
|
/** Bramka wylaczona lub nieskonfigurowana - kod istnieje, ale nikt go nie dostal. */
|
||||||
|
GATEWAY_DISABLED,
|
||||||
|
/** Bramka odrzucila wysylke. */
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
public record OtpDispatch(OtpStatus status, String detail) {
|
||||||
|
public boolean delivered() {
|
||||||
|
return status == OtpStatus.SENT;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,4 +21,15 @@ public final class AccountRequests {
|
|||||||
|
|
||||||
public record VerifyPhoneRequest(@NotBlank @Email String email, @NotBlank String code) {
|
public record VerifyPhoneRequest(@NotBlank @Email String email, @NotBlank String code) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Zmiana hasla z poziomu ustawien konta - wymaga podania dotychczasowego hasla. */
|
||||||
|
public record ChangePasswordRequest(
|
||||||
|
@NotBlank String currentPassword,
|
||||||
|
@NotBlank @Size(min = 8, max = 100) String newPassword
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Usuniecie wlasnego konta - potwierdzane haslem, zeby przejety token nie wystarczyl. */
|
||||||
|
public record DeleteAccountRequest(@NotBlank String password) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import pl.polskalokalnie.user.PreferredLanguage;
|
|||||||
|
|
||||||
public record UpdateProfileRequest(
|
public record UpdateProfileRequest(
|
||||||
@NotBlank String fullName,
|
@NotBlank String fullName,
|
||||||
|
// Pusty nick oznacza "nie zmieniaj" - dotychczasowa nazwa uzytkownika zostaje.
|
||||||
|
String nick,
|
||||||
String phone,
|
String phone,
|
||||||
String birthDate,
|
String birthDate,
|
||||||
String address,
|
String address,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public record UserResponse(
|
|||||||
Long id,
|
Long id,
|
||||||
String email,
|
String email,
|
||||||
String fullName,
|
String fullName,
|
||||||
|
String nick,
|
||||||
Role role,
|
Role role,
|
||||||
AuthProvider provider,
|
AuthProvider provider,
|
||||||
AccountType accountType,
|
AccountType accountType,
|
||||||
@@ -33,6 +34,7 @@ public record UserResponse(
|
|||||||
user.getId(),
|
user.getId(),
|
||||||
user.getEmail(),
|
user.getEmail(),
|
||||||
user.getFullName(),
|
user.getFullName(),
|
||||||
|
user.getNick(),
|
||||||
user.getRole(),
|
user.getRole(),
|
||||||
user.getProvider(),
|
user.getProvider(),
|
||||||
user.getAccountType(),
|
user.getAccountType(),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import pl.polskalokalnie.promotion.PromotionPlan;
|
|||||||
import pl.polskalokalnie.promotion.PromotionPlanRepository;
|
import pl.polskalokalnie.promotion.PromotionPlanRepository;
|
||||||
import pl.polskalokalnie.user.AppUser;
|
import pl.polskalokalnie.user.AppUser;
|
||||||
import pl.polskalokalnie.user.AuthProvider;
|
import pl.polskalokalnie.user.AuthProvider;
|
||||||
|
import pl.polskalokalnie.user.NickService;
|
||||||
import pl.polskalokalnie.user.Role;
|
import pl.polskalokalnie.user.Role;
|
||||||
import pl.polskalokalnie.user.UserRepository;
|
import pl.polskalokalnie.user.UserRepository;
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ public class DataSeeder implements CommandLineRunner {
|
|||||||
private final PromotionPlanRepository promotionPlanRepository;
|
private final PromotionPlanRepository promotionPlanRepository;
|
||||||
private final PromotionPackageRepository promotionPackageRepository;
|
private final PromotionPackageRepository promotionPackageRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final NickService nickService;
|
||||||
private final String adminEmail;
|
private final String adminEmail;
|
||||||
private final String adminPassword;
|
private final String adminPassword;
|
||||||
private final String adminName;
|
private final String adminName;
|
||||||
@@ -42,6 +44,7 @@ public class DataSeeder implements CommandLineRunner {
|
|||||||
PromotionPlanRepository promotionPlanRepository,
|
PromotionPlanRepository promotionPlanRepository,
|
||||||
PromotionPackageRepository promotionPackageRepository,
|
PromotionPackageRepository promotionPackageRepository,
|
||||||
PasswordEncoder passwordEncoder,
|
PasswordEncoder passwordEncoder,
|
||||||
|
NickService nickService,
|
||||||
@Value("${app.admin.email:admin@mieszko.pl}") String adminEmail,
|
@Value("${app.admin.email:admin@mieszko.pl}") String adminEmail,
|
||||||
@Value("${app.admin.password:Admin123!}") String adminPassword,
|
@Value("${app.admin.password:Admin123!}") String adminPassword,
|
||||||
@Value("${app.admin.name:Administrator Polska Lokalnie}") String adminName
|
@Value("${app.admin.name:Administrator Polska Lokalnie}") String adminName
|
||||||
@@ -52,6 +55,7 @@ public class DataSeeder implements CommandLineRunner {
|
|||||||
this.promotionPlanRepository = promotionPlanRepository;
|
this.promotionPlanRepository = promotionPlanRepository;
|
||||||
this.promotionPackageRepository = promotionPackageRepository;
|
this.promotionPackageRepository = promotionPackageRepository;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.nickService = nickService;
|
||||||
this.adminEmail = adminEmail;
|
this.adminEmail = adminEmail;
|
||||||
this.adminPassword = adminPassword;
|
this.adminPassword = adminPassword;
|
||||||
this.adminName = adminName;
|
this.adminName = adminName;
|
||||||
@@ -138,6 +142,7 @@ public class DataSeeder implements CommandLineRunner {
|
|||||||
AppUser admin = new AppUser();
|
AppUser admin = new AppUser();
|
||||||
admin.setEmail(adminEmail.toLowerCase());
|
admin.setEmail(adminEmail.toLowerCase());
|
||||||
admin.setFullName(adminName);
|
admin.setFullName(adminName);
|
||||||
|
admin.setNick(nickService.generateUnique(adminEmail.split("@")[0]));
|
||||||
admin.setPasswordHash(passwordEncoder.encode(adminPassword));
|
admin.setPasswordHash(passwordEncoder.encode(adminPassword));
|
||||||
admin.setRole(Role.ADMIN);
|
admin.setRole(Role.ADMIN);
|
||||||
admin.setProvider(AuthProvider.LOCAL);
|
admin.setProvider(AuthProvider.LOCAL);
|
||||||
|
|||||||
@@ -41,6 +41,30 @@ public class SchemaFixer {
|
|||||||
jdbcTemplate.execute(
|
jdbcTemplate.execute(
|
||||||
"ALTER TABLE app_users ALTER COLUMN preferred_language SET NOT NULL"
|
"ALTER TABLE app_users ALTER COLUMN preferred_language SET NOT NULL"
|
||||||
);
|
);
|
||||||
|
// Publiczna nazwa uzytkownika. Istniejacym kontom nadajemy ja z czesci adresu e-mail przed @,
|
||||||
|
// z numerem na koncu przy kolizji - dopiero po wypelnieniu wszystkich wierszy zakladamy UNIQUE.
|
||||||
|
jdbcTemplate.execute(
|
||||||
|
"ALTER TABLE app_users ADD COLUMN IF NOT EXISTS nick VARCHAR(40)"
|
||||||
|
);
|
||||||
|
jdbcTemplate.execute(
|
||||||
|
"UPDATE app_users u SET nick = base.candidate || CASE WHEN base.rn = 1 THEN '' ELSE base.rn::text END "
|
||||||
|
+ "FROM ( "
|
||||||
|
+ " SELECT id, candidate, ROW_NUMBER() OVER (PARTITION BY candidate ORDER BY id) AS rn FROM ( "
|
||||||
|
+ " SELECT id, COALESCE(NULLIF(regexp_replace(lower(split_part(email, '@', 1)), '[^a-z0-9._-]', '', 'g'), ''), 'uzytkownik') AS candidate "
|
||||||
|
+ " FROM app_users WHERE nick IS NULL "
|
||||||
|
+ " ) normalized "
|
||||||
|
+ ") base "
|
||||||
|
+ "WHERE u.id = base.id AND u.nick IS NULL"
|
||||||
|
);
|
||||||
|
jdbcTemplate.execute(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS app_users_nick_key ON app_users (nick)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Odstep miedzy kolejnymi SMS-ami z kodem - kolumna dochodzi do istniejacej tabeli.
|
||||||
|
jdbcTemplate.execute(
|
||||||
|
"ALTER TABLE IF EXISTS phone_otps ADD COLUMN IF NOT EXISTS last_sent_at TIMESTAMP"
|
||||||
|
);
|
||||||
|
|
||||||
jdbcTemplate.execute(
|
jdbcTemplate.execute(
|
||||||
"ALTER TABLE IF EXISTS listing_report_attachments ADD COLUMN IF NOT EXISTS file_type VARCHAR(120)"
|
"ALTER TABLE IF EXISTS listing_report_attachments ADD COLUMN IF NOT EXISTS file_type VARCHAR(120)"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,9 +38,14 @@ public class SmsSender {
|
|||||||
int timeout = config.getTimeoutSeconds() != null && config.getTimeoutSeconds() > 0
|
int timeout = config.getTimeoutSeconds() != null && config.getTimeoutSeconds() > 0
|
||||||
? config.getTimeoutSeconds() : 10;
|
? config.getTimeoutSeconds() : 10;
|
||||||
|
|
||||||
|
String recipient = normalizePhone(phone);
|
||||||
|
if (recipient.isBlank()) {
|
||||||
|
return SendResult.failure("Pusty numer telefonu odbiorcy");
|
||||||
|
}
|
||||||
|
|
||||||
ObjectNode payload = objectMapper.createObjectNode();
|
ObjectNode payload = objectMapper.createObjectNode();
|
||||||
payload.put("api_key", config.getApiKey());
|
payload.put("api_key", config.getApiKey());
|
||||||
payload.put("to", phone == null ? "" : phone.trim());
|
payload.put("to", recipient);
|
||||||
payload.put("message", message == null ? "" : message);
|
payload.put("message", message == null ? "" : message);
|
||||||
String creator = config.getCreator() == null || config.getCreator().isBlank() ? "API" : config.getCreator();
|
String creator = config.getCreator() == null || config.getCreator().isBlank() ? "API" : config.getCreator();
|
||||||
payload.put("creator", creator);
|
payload.put("creator", creator);
|
||||||
@@ -75,6 +80,20 @@ public class SmsSender {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Numery trzymamy w postaci czytelnej dla czlowieka ("+48 601 234 567"), a bramka oczekuje
|
||||||
|
* samego numeru bez spacji i separatorow. Zostawiamy wiodacy plus i cyfry.
|
||||||
|
*/
|
||||||
|
static String normalizePhone(String phone) {
|
||||||
|
if (phone == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String trimmed = phone.trim();
|
||||||
|
boolean international = trimmed.startsWith("+");
|
||||||
|
String digits = trimmed.replaceAll("\\D", "");
|
||||||
|
return digits.isEmpty() ? "" : (international ? "+" + digits : digits);
|
||||||
|
}
|
||||||
|
|
||||||
private JsonNode tryParse(String body) {
|
private JsonNode tryParse(String body) {
|
||||||
if (body == null || body.isBlank()) {
|
if (body == null || body.isBlank()) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ public class AppUser {
|
|||||||
@Column(length = 120)
|
@Column(length = 120)
|
||||||
private String fullName;
|
private String fullName;
|
||||||
|
|
||||||
|
// Publiczna nazwa uzytkownika (polskalokalnie.pl/u/{nick}). Nadawana automatycznie przy rejestracji
|
||||||
|
// z czesci adresu e-mail przed @, pozniej zmienialna w ustawieniach konta. Unikalna w skali serwisu.
|
||||||
|
@Column(unique = true, length = 40)
|
||||||
|
private String nick;
|
||||||
|
|
||||||
// Nullable: konta zakladane przez logowanie spoleczne nie maja lokalnego hasla.
|
// Nullable: konta zakladane przez logowanie spoleczne nie maja lokalnego hasla.
|
||||||
@Column(length = 100)
|
@Column(length = 100)
|
||||||
private String passwordHash;
|
private String passwordHash;
|
||||||
@@ -113,6 +118,14 @@ public class AppUser {
|
|||||||
this.fullName = fullName;
|
this.fullName = fullName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getNick() {
|
||||||
|
return nick;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNick(String nick) {
|
||||||
|
this.nick = nick;
|
||||||
|
}
|
||||||
|
|
||||||
public String getPasswordHash() {
|
public String getPasswordHash() {
|
||||||
return passwordHash;
|
return passwordHash;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package pl.polskalokalnie.user;
|
||||||
|
|
||||||
|
/** Jednostka powierzchni: metry kwadratowe albo stopy kwadratowe. */
|
||||||
|
public enum AreaUnit {
|
||||||
|
M2,
|
||||||
|
FT2
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package pl.polskalokalnie.user;
|
||||||
|
|
||||||
|
/** Waluta, w ktorej uzytkownik chce widziec ceny ofert. */
|
||||||
|
public enum Currency {
|
||||||
|
PLN,
|
||||||
|
EUR,
|
||||||
|
USD
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package pl.polskalokalnie.user;
|
||||||
|
|
||||||
|
import java.text.Normalizer;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nadawanie i walidacja publicznej nazwy uzytkownika (nick).
|
||||||
|
*
|
||||||
|
* Nick powstaje automatycznie przy zakladaniu konta - z czesci adresu e-mail przed @ - zeby uzytkownik
|
||||||
|
* nie musial go wymyslac przy rejestracji, a ustawienia konta mialy od razu wypelniona wartosc.
|
||||||
|
* Uzytkownik moze go pozniej zmienic; unikalnosc pilnuje kolumna z indeksem UNIQUE i sprawdzenie tutaj.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class NickService {
|
||||||
|
|
||||||
|
public static final int MIN_LENGTH = 3;
|
||||||
|
public static final int MAX_LENGTH = 40;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nazwy zastrzezone - nick jest adresem profilu, wiec nie moze sugerowac, ze konto nalezy
|
||||||
|
* do serwisu albo jego obslugi. Bez tego dowolna osoba moze zostac "administrator".
|
||||||
|
*/
|
||||||
|
private static final Set<String> RESERVED = Set.of(
|
||||||
|
"admin", "administrator", "administracja", "moderator", "moderacja",
|
||||||
|
"polskalokalnie", "polska-lokalnie", "polska.lokalnie", "pl",
|
||||||
|
"biuro", "kontakt", "pomoc", "support", "obsluga", "bok",
|
||||||
|
"serwis", "system", "root", "null", "undefined",
|
||||||
|
"ustawienia", "konto", "profil", "oferta", "ogloszenie", "wiadomosci");
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public NickService(UserRepository userRepository) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zamienia dowolny tekst na dozwolony ksztalt nicka: male litery bez polskich znakow, cyfry,
|
||||||
|
* kropka, myslnik i podkreslnik. Pusty wynik oznacza, ze z podanego tekstu nie da sie zbudowac nicka.
|
||||||
|
*/
|
||||||
|
public String normalize(String raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String withoutDiacritics = Normalizer.normalize(raw.trim(), Normalizer.Form.NFD)
|
||||||
|
.replaceAll("\\p{InCombiningDiacriticalMarks}+", "")
|
||||||
|
.replace("ł", "l")
|
||||||
|
.replace("Ł", "L");
|
||||||
|
String cleaned = withoutDiacritics.toLowerCase(Locale.ROOT)
|
||||||
|
.replaceAll("[^a-z0-9._-]", "")
|
||||||
|
// Powtorzone separatory zwijamy do jednego: "jan..kowalski" i "jan.kowalski" wygladaja
|
||||||
|
// niemal identycznie, a to nazwa publiczna - takie pary uzywa sie do podszywania sie.
|
||||||
|
.replaceAll("[._-]{2,}", ".")
|
||||||
|
.replaceAll("^[._-]+", "")
|
||||||
|
.replaceAll("[._-]+$", "");
|
||||||
|
return cleaned.length() > MAX_LENGTH ? cleaned.substring(0, MAX_LENGTH) : cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buduje wolny nick na podstawie propozycji. Przy kolizji dokleja kolejny numer, wiec dwie osoby
|
||||||
|
* o tym samym imieniu i nazwisku dostana rozne nicki.
|
||||||
|
*/
|
||||||
|
public String generateUnique(String proposal) {
|
||||||
|
return generateUnique(proposal, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jak wyzej, ale nick nalezacy do {@code owner} nie jest traktowany jako zajety - dzieki temu
|
||||||
|
* propozycja dla wlasnego konta nie dostaje niepotrzebnego sufiksu.
|
||||||
|
*/
|
||||||
|
public String generateUnique(String proposal, AppUser owner) {
|
||||||
|
String base = normalize(proposal);
|
||||||
|
if (base.length() < MIN_LENGTH) {
|
||||||
|
base = ("uzytkownik" + base);
|
||||||
|
base = base.length() > MAX_LENGTH ? base.substring(0, MAX_LENGTH) : base;
|
||||||
|
}
|
||||||
|
if (isFree(base, owner)) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
for (int suffix = 2; suffix < 10_000; suffix++) {
|
||||||
|
String candidate = withSuffix(base, suffix);
|
||||||
|
if (isFree(candidate, owner)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new ResponseStatusException(HttpStatus.CONFLICT, "Nie udało się nadać nazwy użytkownika");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nick z imienia i nazwiska ("Jan Kowalski" -> "jan.kowalski"). Gdy z nazwy nie da sie zbudowac
|
||||||
|
* sensownego nicka (np. konto spoleczne bez nazwy), wraca do czesci adresu e-mail przed @.
|
||||||
|
*/
|
||||||
|
public String generateFromName(String fullName, String email, AppUser owner) {
|
||||||
|
String fromName = normalize(joinNameParts(fullName));
|
||||||
|
if (fromName.length() >= MIN_LENGTH && !RESERVED.contains(fromName)) {
|
||||||
|
return generateUnique(fromName, owner);
|
||||||
|
}
|
||||||
|
int at = email == null ? -1 : email.indexOf('@');
|
||||||
|
String localPart = at > 0 ? email.substring(0, at) : email;
|
||||||
|
return generateUnique(localPart, owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isFree(String candidate, AppUser owner) {
|
||||||
|
if (RESERVED.contains(candidate)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return userRepository.findByNickIgnoreCase(candidate)
|
||||||
|
.map(existing -> owner != null && existing.getId().equals(owner.getId()))
|
||||||
|
.orElse(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Jan Maria Kowalski" -> "jan.kowalski": pierwszy i ostatni czlon, kropka miedzy nimi.
|
||||||
|
private String joinNameParts(String fullName) {
|
||||||
|
if (fullName == null || fullName.isBlank()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String[] parts = fullName.trim().split("\\s+");
|
||||||
|
if (parts.length == 1) {
|
||||||
|
return parts[0];
|
||||||
|
}
|
||||||
|
return parts[0] + "." + parts[parts.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sprawdza nick podany przez uzytkownika. Zwraca znormalizowana wartosc albo rzuca bledem
|
||||||
|
* z komunikatem do pokazania w formularzu.
|
||||||
|
*/
|
||||||
|
public String validateForUser(String raw, AppUser owner) {
|
||||||
|
String normalized = normalize(raw);
|
||||||
|
if (normalized.length() < MIN_LENGTH) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||||
|
"Nazwa użytkownika musi mieć co najmniej " + MIN_LENGTH + " znaki (dozwolone: litery, cyfry, kropka, myślnik, podkreślnik)");
|
||||||
|
}
|
||||||
|
if (RESERVED.contains(normalized)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.CONFLICT, "Ta nazwa użytkownika jest zastrzeżona");
|
||||||
|
}
|
||||||
|
userRepository.findByNickIgnoreCase(normalized).ifPresent(existing -> {
|
||||||
|
if (!existing.getId().equals(owner.getId())) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.CONFLICT, "Ta nazwa użytkownika jest już zajęta");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String withSuffix(String base, int suffix) {
|
||||||
|
String tail = String.valueOf(suffix);
|
||||||
|
int room = MAX_LENGTH - tail.length();
|
||||||
|
String head = base.length() > room ? base.substring(0, room) : base;
|
||||||
|
return head + tail;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package pl.polskalokalnie.user;
|
||||||
|
|
||||||
|
/** Kto widzi profil publiczny uzytkownika. */
|
||||||
|
public enum ProfileVisibility {
|
||||||
|
PUBLIC,
|
||||||
|
CONTACTS,
|
||||||
|
PRIVATE
|
||||||
|
}
|
||||||
@@ -21,24 +21,42 @@ public class PublicProfileController {
|
|||||||
|
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final ListingRepository listingRepository;
|
private final ListingRepository listingRepository;
|
||||||
|
private final UserSettingsRepository userSettingsRepository;
|
||||||
|
|
||||||
public PublicProfileController(UserRepository userRepository, ListingRepository listingRepository) {
|
public PublicProfileController(UserRepository userRepository, ListingRepository listingRepository,
|
||||||
|
UserSettingsRepository userSettingsRepository) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.listingRepository = listingRepository;
|
this.listingRepository = listingRepository;
|
||||||
|
this.userSettingsRepository = userSettingsRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/profile")
|
@GetMapping("/{id}/profile")
|
||||||
public PublicProfileResponse profile(@PathVariable Long id) {
|
public PublicProfileResponse profile(@PathVariable Long id) {
|
||||||
AppUser user = userRepository.findById(id)
|
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")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ten sam profil pod adresem z nazwa uzytkownika - polskalokalnie.pl/u/{nick}. */
|
||||||
|
@GetMapping("/by-nick/{nick}/profile")
|
||||||
|
public PublicProfileResponse profileByNick(@PathVariable String nick) {
|
||||||
|
return toResponse(userRepository.findByNickIgnoreCase(nick)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono takiego użytkownika")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private PublicProfileResponse toResponse(AppUser user) {
|
||||||
List<PropertyListing> listings = user.getEmail() == null
|
List<PropertyListing> listings = user.getEmail() == null
|
||||||
? List.of()
|
? List.of()
|
||||||
: listingRepository.findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(user.getEmail(), ListingStatus.APPROVED);
|
: 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(
|
return new PublicProfileResponse(
|
||||||
user.getId(),
|
user.getId(),
|
||||||
user.getFullName(),
|
user.getFullName(),
|
||||||
|
user.getNick(),
|
||||||
|
settings == null ? null : settings.getBio(),
|
||||||
|
settings == null ? null : settings.getAvatarImage(),
|
||||||
user.getAccountType(),
|
user.getAccountType(),
|
||||||
user.isVerified(),
|
user.isVerified(),
|
||||||
user.getCreatedAt(),
|
user.getCreatedAt(),
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import java.util.List;
|
|||||||
public record PublicProfileResponse(
|
public record PublicProfileResponse(
|
||||||
Long id,
|
Long id,
|
||||||
String fullName,
|
String fullName,
|
||||||
|
String nick,
|
||||||
|
// Krotki opis "o mnie" z ustawien konta - pole opcjonalne, moze byc null.
|
||||||
|
String bio,
|
||||||
|
String avatarImage,
|
||||||
AccountType accountType,
|
AccountType accountType,
|
||||||
boolean verified,
|
boolean verified,
|
||||||
Instant memberSince,
|
Instant memberSince,
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ public interface UserRepository extends JpaRepository<AppUser, Long> {
|
|||||||
|
|
||||||
boolean existsByEmailIgnoreCase(String email);
|
boolean existsByEmailIgnoreCase(String email);
|
||||||
|
|
||||||
|
Optional<AppUser> findByNickIgnoreCase(String nick);
|
||||||
|
|
||||||
|
boolean existsByNickIgnoreCase(String nick);
|
||||||
|
|
||||||
Optional<AppUser> findFirstByRole(Role role);
|
Optional<AppUser> findFirstByRole(Role role);
|
||||||
|
|
||||||
long countByRole(Role role);
|
long countByRole(Role role);
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
package pl.polskalokalnie.user;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import org.hibernate.annotations.ColumnDefault;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ustawienia konta zapisywane po stronie serwera - jeden wiersz na uzytkownika.
|
||||||
|
*
|
||||||
|
* Wczesniej te wartosci zyly wylacznie w useState i localStorage przegladarki, wiec ginely po
|
||||||
|
* odswiezeniu strony i roznily sie miedzy urzadzeniami. Zgody marketingowe celowo NIE trafiaja tutaj:
|
||||||
|
* ich jedynym miejscem pozostaje lead (MarketingConsentController), bo to one steruja kampaniami.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "user_settings")
|
||||||
|
public class UserSettings {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
// --- Profil publiczny ---
|
||||||
|
|
||||||
|
@Column(length = 160)
|
||||||
|
private String bio;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 20)
|
||||||
|
@ColumnDefault("'PUBLIC'")
|
||||||
|
private ProfileVisibility profileVisibility = ProfileVisibility.PUBLIC;
|
||||||
|
|
||||||
|
// Zdjecia trzymamy jako data URL - tak samo jak okladki ogloszen (PropertyListing.coverPhoto).
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String avatarImage;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String coverImage;
|
||||||
|
|
||||||
|
// --- Preferencje prezentacji ---
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 10)
|
||||||
|
@ColumnDefault("'PLN'")
|
||||||
|
private Currency currency = Currency.PLN;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 10)
|
||||||
|
@ColumnDefault("'M2'")
|
||||||
|
private AreaUnit areaUnit = AreaUnit.M2;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@ColumnDefault("false")
|
||||||
|
private boolean directOffersOnly = false;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@ColumnDefault("true")
|
||||||
|
private boolean hideInactiveOffers = true;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@ColumnDefault("true")
|
||||||
|
private boolean saveSearchesOnHome = true;
|
||||||
|
|
||||||
|
// --- Powiadomienia e-mail (zgoda marketingowa jest osobno, na leadzie) ---
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@ColumnDefault("true")
|
||||||
|
private boolean notifySavedSearches = true;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@ColumnDefault("true")
|
||||||
|
private boolean notifyPriceAlerts = true;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@ColumnDefault("true")
|
||||||
|
private boolean notifyMessages = true;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@ColumnDefault("false")
|
||||||
|
private boolean notifyProductNews = false;
|
||||||
|
|
||||||
|
// --- Preferencje wyszukiwania (podpowiadane przy filtrowaniu ofert) ---
|
||||||
|
|
||||||
|
@Column(length = 255)
|
||||||
|
private String searchLocations;
|
||||||
|
|
||||||
|
@Column(length = 40)
|
||||||
|
private String searchPropertyType;
|
||||||
|
|
||||||
|
private Integer searchBudgetMax;
|
||||||
|
|
||||||
|
private Integer searchAreaMin;
|
||||||
|
|
||||||
|
private Integer searchAreaMax;
|
||||||
|
|
||||||
|
private Integer searchRoomsMin;
|
||||||
|
|
||||||
|
private Integer searchRoomsMax;
|
||||||
|
|
||||||
|
public static UserSettings defaultsFor(Long userId) {
|
||||||
|
UserSettings settings = new UserSettings();
|
||||||
|
settings.setUserId(userId);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getUserId() {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserId(Long userId) {
|
||||||
|
this.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBio() {
|
||||||
|
return bio;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBio(String bio) {
|
||||||
|
this.bio = bio;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProfileVisibility getProfileVisibility() {
|
||||||
|
return profileVisibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProfileVisibility(ProfileVisibility profileVisibility) {
|
||||||
|
this.profileVisibility = profileVisibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAvatarImage() {
|
||||||
|
return avatarImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvatarImage(String avatarImage) {
|
||||||
|
this.avatarImage = avatarImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCoverImage() {
|
||||||
|
return coverImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCoverImage(String coverImage) {
|
||||||
|
this.coverImage = coverImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Currency getCurrency() {
|
||||||
|
return currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCurrency(Currency currency) {
|
||||||
|
this.currency = currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AreaUnit getAreaUnit() {
|
||||||
|
return areaUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAreaUnit(AreaUnit areaUnit) {
|
||||||
|
this.areaUnit = areaUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDirectOffersOnly() {
|
||||||
|
return directOffersOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDirectOffersOnly(boolean directOffersOnly) {
|
||||||
|
this.directOffersOnly = directOffersOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isHideInactiveOffers() {
|
||||||
|
return hideInactiveOffers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setHideInactiveOffers(boolean hideInactiveOffers) {
|
||||||
|
this.hideInactiveOffers = hideInactiveOffers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSaveSearchesOnHome() {
|
||||||
|
return saveSearchesOnHome;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSaveSearchesOnHome(boolean saveSearchesOnHome) {
|
||||||
|
this.saveSearchesOnHome = saveSearchesOnHome;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isNotifySavedSearches() {
|
||||||
|
return notifySavedSearches;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNotifySavedSearches(boolean notifySavedSearches) {
|
||||||
|
this.notifySavedSearches = notifySavedSearches;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isNotifyPriceAlerts() {
|
||||||
|
return notifyPriceAlerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNotifyPriceAlerts(boolean notifyPriceAlerts) {
|
||||||
|
this.notifyPriceAlerts = notifyPriceAlerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isNotifyMessages() {
|
||||||
|
return notifyMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNotifyMessages(boolean notifyMessages) {
|
||||||
|
this.notifyMessages = notifyMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isNotifyProductNews() {
|
||||||
|
return notifyProductNews;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNotifyProductNews(boolean notifyProductNews) {
|
||||||
|
this.notifyProductNews = notifyProductNews;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSearchLocations() {
|
||||||
|
return searchLocations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSearchLocations(String searchLocations) {
|
||||||
|
this.searchLocations = searchLocations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSearchPropertyType() {
|
||||||
|
return searchPropertyType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSearchPropertyType(String searchPropertyType) {
|
||||||
|
this.searchPropertyType = searchPropertyType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getSearchBudgetMax() {
|
||||||
|
return searchBudgetMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSearchBudgetMax(Integer searchBudgetMax) {
|
||||||
|
this.searchBudgetMax = searchBudgetMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getSearchAreaMin() {
|
||||||
|
return searchAreaMin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSearchAreaMin(Integer searchAreaMin) {
|
||||||
|
this.searchAreaMin = searchAreaMin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getSearchAreaMax() {
|
||||||
|
return searchAreaMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSearchAreaMax(Integer searchAreaMax) {
|
||||||
|
this.searchAreaMax = searchAreaMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getSearchRoomsMin() {
|
||||||
|
return searchRoomsMin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSearchRoomsMin(Integer searchRoomsMin) {
|
||||||
|
this.searchRoomsMin = searchRoomsMin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getSearchRoomsMax() {
|
||||||
|
return searchRoomsMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSearchRoomsMax(Integer searchRoomsMax) {
|
||||||
|
this.searchRoomsMax = searchRoomsMax;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package pl.polskalokalnie.user;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ustawienia konta zalogowanego uzytkownika (/konto/ustawienia). Endpoint wymaga uwierzytelnienia
|
||||||
|
* przez regule anyRequest().authenticated() w SecurityConfig.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/me/settings")
|
||||||
|
public class UserSettingsController {
|
||||||
|
|
||||||
|
private final UserSettingsService service;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public UserSettingsController(UserSettingsService service, UserRepository userRepository) {
|
||||||
|
this.service = service;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public UserSettingsResponse get(Authentication authentication) {
|
||||||
|
return service.get(currentUserId(authentication));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping
|
||||||
|
public UserSettingsResponse update(Authentication authentication, @RequestBody UserSettingsRequest request) {
|
||||||
|
return service.update(currentUserId(authentication), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long currentUserId(Authentication authentication) {
|
||||||
|
return userRepository.findByEmailIgnoreCase(authentication.getName())
|
||||||
|
.map(AppUser::getId)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package pl.polskalokalnie.user;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface UserSettingsRepository extends JpaRepository<UserSettings, Long> {
|
||||||
|
|
||||||
|
Optional<UserSettings> findByUserId(Long userId);
|
||||||
|
|
||||||
|
void deleteByUserId(Long userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package pl.polskalokalnie.user;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zapis ustawien konta. Pola obiektowe (null) oznaczaja "zostaw jak bylo" - dzieki temu pojedyncza
|
||||||
|
* sekcja formularza moze zapisac sie sama, bez przesylania calego stanu strony.
|
||||||
|
*/
|
||||||
|
public record UserSettingsRequest(
|
||||||
|
String bio,
|
||||||
|
ProfileVisibility profileVisibility,
|
||||||
|
String avatarImage,
|
||||||
|
String coverImage,
|
||||||
|
Currency currency,
|
||||||
|
AreaUnit areaUnit,
|
||||||
|
Boolean directOffersOnly,
|
||||||
|
Boolean hideInactiveOffers,
|
||||||
|
Boolean saveSearchesOnHome,
|
||||||
|
Boolean notifySavedSearches,
|
||||||
|
Boolean notifyPriceAlerts,
|
||||||
|
Boolean notifyMessages,
|
||||||
|
Boolean notifyProductNews,
|
||||||
|
String searchLocations,
|
||||||
|
String searchPropertyType,
|
||||||
|
Integer searchBudgetMax,
|
||||||
|
Integer searchAreaMin,
|
||||||
|
Integer searchAreaMax,
|
||||||
|
Integer searchRoomsMin,
|
||||||
|
Integer searchRoomsMax
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package pl.polskalokalnie.user;
|
||||||
|
|
||||||
|
/** Komplet ustawien konta odsylany do przegladarki. */
|
||||||
|
public record UserSettingsResponse(
|
||||||
|
String bio,
|
||||||
|
ProfileVisibility profileVisibility,
|
||||||
|
String avatarImage,
|
||||||
|
String coverImage,
|
||||||
|
Currency currency,
|
||||||
|
AreaUnit areaUnit,
|
||||||
|
boolean directOffersOnly,
|
||||||
|
boolean hideInactiveOffers,
|
||||||
|
boolean saveSearchesOnHome,
|
||||||
|
boolean notifySavedSearches,
|
||||||
|
boolean notifyPriceAlerts,
|
||||||
|
boolean notifyMessages,
|
||||||
|
boolean notifyProductNews,
|
||||||
|
String searchLocations,
|
||||||
|
String searchPropertyType,
|
||||||
|
Integer searchBudgetMax,
|
||||||
|
Integer searchAreaMin,
|
||||||
|
Integer searchAreaMax,
|
||||||
|
Integer searchRoomsMin,
|
||||||
|
Integer searchRoomsMax
|
||||||
|
) {
|
||||||
|
public static UserSettingsResponse from(UserSettings settings) {
|
||||||
|
return new UserSettingsResponse(
|
||||||
|
settings.getBio(),
|
||||||
|
settings.getProfileVisibility(),
|
||||||
|
settings.getAvatarImage(),
|
||||||
|
settings.getCoverImage(),
|
||||||
|
settings.getCurrency(),
|
||||||
|
settings.getAreaUnit(),
|
||||||
|
settings.isDirectOffersOnly(),
|
||||||
|
settings.isHideInactiveOffers(),
|
||||||
|
settings.isSaveSearchesOnHome(),
|
||||||
|
settings.isNotifySavedSearches(),
|
||||||
|
settings.isNotifyPriceAlerts(),
|
||||||
|
settings.isNotifyMessages(),
|
||||||
|
settings.isNotifyProductNews(),
|
||||||
|
settings.getSearchLocations(),
|
||||||
|
settings.getSearchPropertyType(),
|
||||||
|
settings.getSearchBudgetMax(),
|
||||||
|
settings.getSearchAreaMin(),
|
||||||
|
settings.getSearchAreaMax(),
|
||||||
|
settings.getSearchRoomsMin(),
|
||||||
|
settings.getSearchRoomsMax()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package pl.polskalokalnie.user;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import pl.polskalokalnie.moderation.TextModerationService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Odczyt i zapis ustawien konta. Brak wiersza w bazie oznacza ustawienia domyslne - tworzymy go
|
||||||
|
* dopiero przy pierwszym zapisie, wiec konta, ktore nigdy nic nie zmienialy, nie zasmiecaja tabeli.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class UserSettingsService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gorny limit na zdjecie w postaci data URL. Front skaluje obrazy przed wyslaniem (ok. 100-200 KB),
|
||||||
|
* wiec ten limit lapie tylko proby obejscia formularza. Trzymamy go ponizej limitu ciala zadania
|
||||||
|
* w nginx, zeby uzytkownik dostal czytelny blad zamiast 413 z proxy.
|
||||||
|
*/
|
||||||
|
private static final int MAX_IMAGE_DATA_URL_LENGTH = 700_000;
|
||||||
|
|
||||||
|
private static final int MAX_BIO_LENGTH = 160;
|
||||||
|
|
||||||
|
private final UserSettingsRepository repository;
|
||||||
|
private final TextModerationService textModerationService;
|
||||||
|
|
||||||
|
public UserSettingsService(UserSettingsRepository repository, TextModerationService textModerationService) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.textModerationService = textModerationService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserSettingsResponse get(Long userId) {
|
||||||
|
return UserSettingsResponse.from(
|
||||||
|
repository.findByUserId(userId).orElseGet(() -> UserSettings.defaultsFor(userId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zapis czesciowy: null zostawia dotychczasowa wartosc, pusty tekst ja czysci. Dzieki temu
|
||||||
|
* kazda sekcja formularza moze zapisac wylacznie swoje pola.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public UserSettingsResponse update(Long userId, UserSettingsRequest request) {
|
||||||
|
UserSettings settings = repository.findByUserId(userId)
|
||||||
|
.orElseGet(() -> UserSettings.defaultsFor(userId));
|
||||||
|
|
||||||
|
if (request.bio() != null) {
|
||||||
|
String bio = request.bio().trim();
|
||||||
|
if (bio.length() > MAX_BIO_LENGTH) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Biografia może mieć maksymalnie " + MAX_BIO_LENGTH + " znaków");
|
||||||
|
}
|
||||||
|
textModerationService.validateOrThrow(bio);
|
||||||
|
settings.setBio(bio.isEmpty() ? null : bio);
|
||||||
|
}
|
||||||
|
if (request.profileVisibility() != null) {
|
||||||
|
settings.setProfileVisibility(request.profileVisibility());
|
||||||
|
}
|
||||||
|
if (request.avatarImage() != null) {
|
||||||
|
settings.setAvatarImage(cleanImage(request.avatarImage(), "Zdjęcie profilowe"));
|
||||||
|
}
|
||||||
|
if (request.coverImage() != null) {
|
||||||
|
settings.setCoverImage(cleanImage(request.coverImage(), "Zdjęcie w tle"));
|
||||||
|
}
|
||||||
|
if (request.currency() != null) {
|
||||||
|
settings.setCurrency(request.currency());
|
||||||
|
}
|
||||||
|
if (request.areaUnit() != null) {
|
||||||
|
settings.setAreaUnit(request.areaUnit());
|
||||||
|
}
|
||||||
|
if (request.directOffersOnly() != null) {
|
||||||
|
settings.setDirectOffersOnly(request.directOffersOnly());
|
||||||
|
}
|
||||||
|
if (request.hideInactiveOffers() != null) {
|
||||||
|
settings.setHideInactiveOffers(request.hideInactiveOffers());
|
||||||
|
}
|
||||||
|
if (request.saveSearchesOnHome() != null) {
|
||||||
|
settings.setSaveSearchesOnHome(request.saveSearchesOnHome());
|
||||||
|
}
|
||||||
|
if (request.notifySavedSearches() != null) {
|
||||||
|
settings.setNotifySavedSearches(request.notifySavedSearches());
|
||||||
|
}
|
||||||
|
if (request.notifyPriceAlerts() != null) {
|
||||||
|
settings.setNotifyPriceAlerts(request.notifyPriceAlerts());
|
||||||
|
}
|
||||||
|
if (request.notifyMessages() != null) {
|
||||||
|
settings.setNotifyMessages(request.notifyMessages());
|
||||||
|
}
|
||||||
|
if (request.notifyProductNews() != null) {
|
||||||
|
settings.setNotifyProductNews(request.notifyProductNews());
|
||||||
|
}
|
||||||
|
if (request.searchLocations() != null) {
|
||||||
|
String locations = request.searchLocations().trim();
|
||||||
|
textModerationService.validateOrThrow(locations);
|
||||||
|
settings.setSearchLocations(locations.isEmpty() ? null : locations);
|
||||||
|
}
|
||||||
|
if (request.searchPropertyType() != null) {
|
||||||
|
String type = request.searchPropertyType().trim();
|
||||||
|
settings.setSearchPropertyType(type.isEmpty() ? null : type);
|
||||||
|
}
|
||||||
|
settings.setSearchBudgetMax(pickNumber(request.searchBudgetMax(), settings.getSearchBudgetMax()));
|
||||||
|
settings.setSearchAreaMin(pickNumber(request.searchAreaMin(), settings.getSearchAreaMin()));
|
||||||
|
settings.setSearchAreaMax(pickNumber(request.searchAreaMax(), settings.getSearchAreaMax()));
|
||||||
|
settings.setSearchRoomsMin(pickNumber(request.searchRoomsMin(), settings.getSearchRoomsMin()));
|
||||||
|
settings.setSearchRoomsMax(pickNumber(request.searchRoomsMax(), settings.getSearchRoomsMax()));
|
||||||
|
|
||||||
|
return UserSettingsResponse.from(repository.save(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deleteForUser(Long userId) {
|
||||||
|
repository.deleteByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liczby: wartosc ujemna to sygnal "wyczysc pole" (formularz wysyla -1 dla pustego inputa).
|
||||||
|
private Integer pickNumber(Integer incoming, Integer current) {
|
||||||
|
if (incoming == null) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return incoming < 0 ? null : incoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String cleanImage(String dataUrl, String label) {
|
||||||
|
String trimmed = dataUrl.trim();
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!trimmed.startsWith("data:image/")) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, label + " musi być obrazem przesłanym z formularza");
|
||||||
|
}
|
||||||
|
if (trimmed.length() > MAX_IMAGE_DATA_URL_LENGTH) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE, label + " jest za duże. Wybierz mniejszy plik.");
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package pl.polskalokalnie.send;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Numery telefonow trzymamy w bazie w postaci czytelnej dla czlowieka ("+48 601 234 567"),
|
||||||
|
* a bramka SMS oczekuje samego numeru. Bez normalizacji wysylka konczy sie bledem bramki.
|
||||||
|
*/
|
||||||
|
class SmsSenderTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("usuwa spacje i separatory, zachowujac kierunkowy")
|
||||||
|
void normalizujeNumerZeSpacjami() {
|
||||||
|
assertEquals("+48601234567", SmsSender.normalizePhone("+48 601 234 567"));
|
||||||
|
assertEquals("+48601234567", SmsSender.normalizePhone("+48 601234567"));
|
||||||
|
assertEquals("+48601234567", SmsSender.normalizePhone(" +48-601-234-567 "));
|
||||||
|
assertEquals("+48601234567", SmsSender.normalizePhone("+48 (601) 234 567"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("numer bez kierunkowego zostaje bez plusa")
|
||||||
|
void zachowujeNumerKrajowy() {
|
||||||
|
assertEquals("601234567", SmsSender.normalizePhone("601 234 567"));
|
||||||
|
assertEquals("601234567", SmsSender.normalizePhone("601-234-567"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("pusty lub bezsensowny numer daje pusty wynik")
|
||||||
|
void pustyNumer() {
|
||||||
|
assertEquals("", SmsSender.normalizePhone(null));
|
||||||
|
assertEquals("", SmsSender.normalizePhone(" "));
|
||||||
|
assertEquals("", SmsSender.normalizePhone("brak numeru"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package pl.polskalokalnie.user;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizacja nazwy uzytkownika. Nick jest czescia adresu profilu (/u/{nick}), wiec musi byc
|
||||||
|
* pozbawiony polskich znakow, spacji i wszystkiego, co psuje URL.
|
||||||
|
*/
|
||||||
|
class NickServiceTest {
|
||||||
|
|
||||||
|
// normalize() nie korzysta z repozytorium - do testu wystarczy instancja bez zaleznosci.
|
||||||
|
private final NickService nickService = new NickService(null);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("zamienia polskie znaki na odpowiedniki ASCII")
|
||||||
|
void usuwaPolskieZnaki() {
|
||||||
|
assertEquals("zazolc", nickService.normalize("Zażółć"));
|
||||||
|
assertEquals("lakowski", nickService.normalize("Łąkowski"));
|
||||||
|
assertEquals("jaskowalski", nickService.normalize("Jaś Kowalski!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("zostawia kropke, mysnik i podkreslnik, wycina reszte")
|
||||||
|
void dopuszczaTylkoBezpieczneZnaki() {
|
||||||
|
assertEquals("jan.kowalski", nickService.normalize("jan.kowalski"));
|
||||||
|
assertEquals("jan-kowalski", nickService.normalize("jan-kowalski"));
|
||||||
|
assertEquals("jan_kowalski", nickService.normalize("jan_kowalski"));
|
||||||
|
assertEquals("jankowalski", nickService.normalize("jan kowalski"));
|
||||||
|
assertEquals("jankowalski.etc", nickService.normalize("jan@kowalski/../etc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("zwija powtorzone separatory - inaczej jan..kowalski podszywa sie pod jan.kowalski")
|
||||||
|
void zwijaPowtorzoneSeparatory() {
|
||||||
|
assertEquals("jan.kowalski", nickService.normalize("jan..kowalski"));
|
||||||
|
assertEquals("jan.kowalski", nickService.normalize("jan---kowalski"));
|
||||||
|
assertEquals("jan.kowalski", nickService.normalize("jan_-.kowalski"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("obcina separatory z poczatku i konca")
|
||||||
|
void obcinaSeparatoryNaBrzegach() {
|
||||||
|
assertEquals("kowalski", nickService.normalize("...kowalski---"));
|
||||||
|
assertEquals("kowalski", nickService.normalize("_kowalski_"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("nie przekracza dozwolonej dlugosci")
|
||||||
|
void pilnujeDlugosci() {
|
||||||
|
String result = nickService.normalize("a".repeat(200));
|
||||||
|
assertEquals(NickService.MAX_LENGTH, result.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("z tekstu bez liter i cyfr nie da sie zbudowac nicka")
|
||||||
|
void pustyWynikDlaSmieci() {
|
||||||
|
assertEquals("", nickService.normalize(null));
|
||||||
|
assertEquals("", nickService.normalize(" "));
|
||||||
|
assertTrue(nickService.normalize("!@#$%").isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
+1102
-1020
File diff suppressed because it is too large
Load Diff
@@ -170,7 +170,8 @@ export function PhoneOtpModal({ email, phone, onClose, onVerified }: {
|
|||||||
try {
|
try {
|
||||||
await resendPhoneOtp(email);
|
await resendPhoneOtp(email);
|
||||||
setInfo('Wysłaliśmy nowy kod SMS.');
|
setInfo('Wysłaliśmy nowy kod SMS.');
|
||||||
setCooldown(30);
|
// Zgodne z odstepem wymuszanym przez backend (RESEND_COOLDOWN_SECONDS).
|
||||||
|
setCooldown(60);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(errMsg(e));
|
setError(errMsg(e));
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-7
@@ -7,10 +7,45 @@ export type AccountType = 'PERSONAL' | 'COMPANY';
|
|||||||
export type ContactPreference = 'EMAIL' | 'PHONE' | 'EMAIL_AND_PHONE';
|
export type ContactPreference = 'EMAIL' | 'PHONE' | 'EMAIL_AND_PHONE';
|
||||||
export type PreferredLanguage = 'PL' | 'EN' | 'UK' | 'DE';
|
export type PreferredLanguage = 'PL' | 'EN' | 'UK' | 'DE';
|
||||||
|
|
||||||
|
export type Currency = 'PLN' | 'EUR' | 'USD';
|
||||||
|
export type AreaUnit = 'M2' | 'FT2';
|
||||||
|
export type ProfileVisibility = 'PUBLIC' | 'CONTACTS' | 'PRIVATE';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ustawienia konta trzymane na serwerze (/api/me/settings). Zgody marketingowe celowo tu nie leza -
|
||||||
|
* ich jedynym zrodlem jest /api/me/marketing, bo steruja kwalifikacja do kampanii.
|
||||||
|
*/
|
||||||
|
export type UserSettings = {
|
||||||
|
bio: string | null;
|
||||||
|
profileVisibility: ProfileVisibility;
|
||||||
|
avatarImage: string | null;
|
||||||
|
coverImage: string | null;
|
||||||
|
currency: Currency;
|
||||||
|
areaUnit: AreaUnit;
|
||||||
|
directOffersOnly: boolean;
|
||||||
|
hideInactiveOffers: boolean;
|
||||||
|
saveSearchesOnHome: boolean;
|
||||||
|
notifySavedSearches: boolean;
|
||||||
|
notifyPriceAlerts: boolean;
|
||||||
|
notifyMessages: boolean;
|
||||||
|
notifyProductNews: boolean;
|
||||||
|
searchLocations: string | null;
|
||||||
|
searchPropertyType: string | null;
|
||||||
|
searchBudgetMax: number | null;
|
||||||
|
searchAreaMin: number | null;
|
||||||
|
searchAreaMax: number | null;
|
||||||
|
searchRoomsMin: number | null;
|
||||||
|
searchRoomsMax: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Zapis czesciowy: pominiete pole zostaje bez zmian, pusty tekst je czysci, liczba ujemna zeruje.
|
||||||
|
export type UserSettingsPatch = Partial<Record<keyof UserSettings, unknown>>;
|
||||||
|
|
||||||
export type AuthUser = {
|
export type AuthUser = {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
email: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
|
nick: string | null;
|
||||||
role: Role;
|
role: Role;
|
||||||
provider: AuthProviderName;
|
provider: AuthProviderName;
|
||||||
accountType: AccountType;
|
accountType: AccountType;
|
||||||
@@ -52,11 +87,29 @@ type AuthContextValue = {
|
|||||||
resetPassword: (token: string, password: string) => Promise<void>;
|
resetPassword: (token: string, password: string) => Promise<void>;
|
||||||
verifyPhone: (email: string, code: string) => Promise<void>;
|
verifyPhone: (email: string, code: string) => Promise<void>;
|
||||||
resendPhoneOtp: (email: string) => Promise<void>;
|
resendPhoneOtp: (email: string) => Promise<void>;
|
||||||
updateProfile: (fullName: string, phone?: string, birthDate?: string, address?: string, contactPreference?: ContactPreference, preferredLanguage?: PreferredLanguage) => Promise<AuthUser>;
|
updateProfile: (profile: ProfileUpdate) => Promise<AuthUser>;
|
||||||
|
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
|
||||||
|
deleteAccount: (password: string) => Promise<void>;
|
||||||
|
loadSettings: () => Promise<UserSettings>;
|
||||||
|
saveSettings: (patch: UserSettingsPatch) => Promise<UserSettings>;
|
||||||
refreshUser: () => Promise<AuthUser | null>;
|
refreshUser: () => Promise<AuthUser | null>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /auth/me nadpisuje caly profil, wiec wysylamy komplet pol, a nie tylko zmienione.
|
||||||
|
* Pusty nick oznacza "zostaw dotychczasowy" - backend nie zmienia wtedy nazwy uzytkownika.
|
||||||
|
*/
|
||||||
|
export type ProfileUpdate = {
|
||||||
|
fullName: string;
|
||||||
|
nick?: string;
|
||||||
|
phone?: string;
|
||||||
|
birthDate?: string;
|
||||||
|
address?: string;
|
||||||
|
contactPreference?: ContactPreference;
|
||||||
|
preferredLanguage?: PreferredLanguage;
|
||||||
|
};
|
||||||
|
|
||||||
const TOKEN_KEY = 'polskalokalnie-auth-token';
|
const TOKEN_KEY = 'polskalokalnie-auth-token';
|
||||||
const API_BASE = '/api';
|
const API_BASE = '/api';
|
||||||
|
|
||||||
@@ -217,15 +270,38 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
await apiFetch<void>('/auth/resend-phone-otp', { method: 'POST', body: JSON.stringify({ email }) });
|
await apiFetch<void>('/auth/resend-phone-otp', { method: 'POST', body: JSON.stringify({ email }) });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateProfile = useCallback(
|
const updateProfile = useCallback(async (profile: ProfileUpdate) => {
|
||||||
async (fullName: string, phone?: string, birthDate?: string, address?: string, contactPreference?: ContactPreference, preferredLanguage?: PreferredLanguage) => {
|
|
||||||
const updated = await apiFetch<AuthUser>('/auth/me', {
|
const updated = await apiFetch<AuthUser>('/auth/me', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ fullName, phone, birthDate, address, contactPreference, preferredLanguage }),
|
body: JSON.stringify(profile),
|
||||||
});
|
});
|
||||||
setUser(updated);
|
setUser(updated);
|
||||||
return updated;
|
return updated;
|
||||||
},
|
}, []);
|
||||||
|
|
||||||
|
const changePassword = useCallback(async (currentPassword: string, newPassword: string) => {
|
||||||
|
await apiFetch<void>('/auth/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Po usunieciu konta token jest bezuzyteczny - czyscimy sesje od razu, bez czekania na 401.
|
||||||
|
const deleteAccount = useCallback(async (password: string) => {
|
||||||
|
await apiFetch<void>('/auth/me', {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
window.localStorage.removeItem(TOKEN_KEY);
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSettings = useCallback(async () => apiFetch<UserSettings>('/me/settings'), []);
|
||||||
|
|
||||||
|
const saveSettings = useCallback(
|
||||||
|
async (patch: UserSettingsPatch) =>
|
||||||
|
apiFetch<UserSettings>('/me/settings', { method: 'PUT', body: JSON.stringify(patch) }),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -253,11 +329,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
() => ({
|
() => ({
|
||||||
user, token, loading, login, register, socialLogin,
|
user, token, loading, login, register, socialLogin,
|
||||||
activateAccount, resendActivation, forgotPassword, resetPassword, verifyPhone, resendPhoneOtp,
|
activateAccount, resendActivation, forgotPassword, resetPassword, verifyPhone, resendPhoneOtp,
|
||||||
updateProfile, refreshUser, logout,
|
updateProfile, changePassword, deleteAccount, loadSettings, saveSettings, refreshUser, logout,
|
||||||
}),
|
}),
|
||||||
[user, token, loading, login, register, socialLogin,
|
[user, token, loading, login, register, socialLogin,
|
||||||
activateAccount, resendActivation, forgotPassword, resetPassword, verifyPhone, resendPhoneOtp,
|
activateAccount, resendActivation, forgotPassword, resetPassword, verifyPhone, resendPhoneOtp,
|
||||||
updateProfile, refreshUser, logout],
|
updateProfile, changePassword, deleteAccount, loadSettings, saveSettings, refreshUser, logout],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export const ROUTES = {
|
|||||||
admin: '/admin',
|
admin: '/admin',
|
||||||
listingDetail: '/oferta/:id',
|
listingDetail: '/oferta/:id',
|
||||||
publicProfile: '/profil/:id',
|
publicProfile: '/profil/:id',
|
||||||
|
// Ten sam profil pod nazwą użytkownika - adres pokazywany w ustawieniach konta.
|
||||||
|
publicProfileByNick: '/u/:nick',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES];
|
export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES];
|
||||||
@@ -58,6 +60,12 @@ export function listingEditPath(id: number): string {
|
|||||||
return `/edytuj-ogloszenie/${id}`;
|
return `/edytuj-ogloszenie/${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Profil pod nazwą użytkownika. Nick jest unikalny w skali serwisu, więc jednoznacznie
|
||||||
|
// wskazuje osobę - imię i nazwisko może się powtarzać u wielu kont.
|
||||||
|
export function publicProfileNickPath(nick: string): string {
|
||||||
|
return `/u/${encodeURIComponent(nick)}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Negocjacje dotyczą konkretnej oferty - bez identyfikatora pokazujemy pierwszą z listy.
|
// Negocjacje dotyczą konkretnej oferty - bez identyfikatora pokazujemy pierwszą z listy.
|
||||||
export function negotiationPath(offerId?: number | null): string {
|
export function negotiationPath(offerId?: number | null): string {
|
||||||
return offerId ? `${ROUTES.negotiation}/${offerId}` : ROUTES.negotiation;
|
return offerId ? `${ROUTES.negotiation}/${offerId}` : ROUTES.negotiation;
|
||||||
|
|||||||
+280
-4
@@ -21712,10 +21712,6 @@ svg {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-action-row {
|
|
||||||
grid-template-columns: minmax(0, 1fr) 106px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.phone-row {
|
.phone-row {
|
||||||
grid-template-columns: 132px minmax(0, 1fr) auto;
|
grid-template-columns: 132px minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
@@ -29892,3 +29888,283 @@ a.listing-detail-back {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
/konto/ustawienia - bloki dodane przy scaleniu edycji profilu i bezpieczenstwa
|
||||||
|
w jedna strone. Reszta wyglądu korzysta z istniejacych klas settings-*.
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* Karta danych osobowych zajmuje cala szerokosc siatki. */
|
||||||
|
.settings-card-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bezpieczenstwo stoi obok jednokolumnowych powiadomien i domyka rzad zamiast zostawiac luke. */
|
||||||
|
.settings-card-double {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-banner {
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 9px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-banner.success {
|
||||||
|
background: #eaf7f0;
|
||||||
|
border: 1px solid #bfe3d0;
|
||||||
|
color: #1f6b47;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-banner.error {
|
||||||
|
background: #fdeced;
|
||||||
|
border: 1px solid #f3c2c6;
|
||||||
|
color: #a32431;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-banner.info {
|
||||||
|
background: #eef4fd;
|
||||||
|
border: 1px solid #c8dbf5;
|
||||||
|
color: #2c4f80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-list .toggle-hint {
|
||||||
|
color: #6c7b8e;
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wiersz z lista rozwijana (waluta, jednostki) - ten sam uklad co wiersz z przelacznikiem. */
|
||||||
|
.settings-list .settings-select-row {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-list .settings-select-row select {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d9e2ec;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #30445d;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-search-form,
|
||||||
|
.settings-password-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-password-form h3 {
|
||||||
|
color: #1a2c46;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-range-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Potwierdzenie numeru pod polem telefonu - pokazuje sie dopiero dla zapisanego numeru. */
|
||||||
|
.phone-verify-inline {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-verify-inline small {
|
||||||
|
color: #6c7b8e;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wyglad przycisku potwierdzenia byl przypiety do .phone-action-buttons ze starego,
|
||||||
|
dwuprzyciskowego ukladu. Tutaj przycisk stoi sam pod polem, wiec styl jest wlasny. */
|
||||||
|
.phone-verify-inline .phone-confirm-button {
|
||||||
|
align-items: center;
|
||||||
|
background: #edf5ff;
|
||||||
|
border: 1px solid #d6e4f5;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #2b4e74;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-verify-inline .phone-confirm-button:hover:not(:disabled) {
|
||||||
|
background: #e2eefc;
|
||||||
|
border-color: #bcd5ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-verify-inline .phone-confirm-button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-status-list article {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
padding: 3px 9px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Znacznik statusu jest spanem, wiec bez tego lapie sie na regule slotu ikony
|
||||||
|
(.settings-list article > span => 22x22 px) i tekst wychodzi poza karte. */
|
||||||
|
.settings-list article > span.status-pill {
|
||||||
|
align-items: center;
|
||||||
|
display: inline-flex;
|
||||||
|
height: auto;
|
||||||
|
justify-self: end;
|
||||||
|
line-height: 1.3;
|
||||||
|
place-items: center;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.ok {
|
||||||
|
background: #eaf7f0;
|
||||||
|
color: #1f6b47;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.warn {
|
||||||
|
background: #fdf3e3;
|
||||||
|
color: #8a5a12;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Link podgladu profilu jest teraz czwartym dzieckiem karty widocznosci - bez tego
|
||||||
|
wpadalby do waskiej kolumny z ikona i wychodzil poza karte. */
|
||||||
|
.settings-list article.profile-visibility-card > .profile-preview-cta {
|
||||||
|
grid-column: 2;
|
||||||
|
justify-self: start;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-danger-zone {
|
||||||
|
align-items: center;
|
||||||
|
background: #fdf6f6;
|
||||||
|
border: 1px solid #f3d2d5;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-danger-zone strong {
|
||||||
|
align-items: center;
|
||||||
|
color: #a32431;
|
||||||
|
display: flex;
|
||||||
|
font-size: 13px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-danger-zone small {
|
||||||
|
color: #6c7b8e;
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* W strefie niebezpiecznej przycisk stoi obok opisu, wiec nie rozciaga sie na cala szerokosc. */
|
||||||
|
.settings-danger-zone .delete-account-button {
|
||||||
|
padding: 0 18px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status-btn.danger {
|
||||||
|
background: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.settings-range-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Siatka ustawien ma tu jedna kolumne - bez tego span 2 dorobilby druga, pusta. */
|
||||||
|
.settings-card-double {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-danger-zone {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zdjecie profilowe i opis "o mnie" na profilu publicznym - dane z /konto/ustawienia. */
|
||||||
|
.public-profile-avatar img {
|
||||||
|
border-radius: 999px;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-bio {
|
||||||
|
color: #34495f;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-bio-empty {
|
||||||
|
color: #8593a5;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ostrzezenie o zmianie adresu profilu - nick jest linkiem publicznym, wiec zmiana musi byc swiadoma. */
|
||||||
|
.nick-change-warning {
|
||||||
|
align-items: center;
|
||||||
|
background: #fdf6e7;
|
||||||
|
border: 1px solid #f0dcae;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #8a5a12;
|
||||||
|
display: flex;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 750;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nick-change-warning svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 13px;
|
||||||
|
width: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nick nadaje system - pole jest do odczytu, wiec nie udaje edytowalnego. */
|
||||||
|
.input-action-row > input[readonly] {
|
||||||
|
background: #f7f9fc;
|
||||||
|
color: #4a5b70;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Druga kolumna byla na sztywno 106 px - pod krotkie etykiety w stylu "Zmien nick". Dluzszy
|
||||||
|
napis z white-space: nowrap nie mial sie gdzie zmiescic i wychodzil poza kartę.
|
||||||
|
Kolumna dopasowuje sie teraz do tresci przycisku. */
|
||||||
|
.input-action-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
/* Na waskim ekranie przycisk pod polem - inaczej input zostaje z paroma pikselami. */
|
||||||
|
.input-action-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -262,7 +262,6 @@ test.describe('Layout konta', () => {
|
|||||||
ROUTES.accountListings,
|
ROUTES.accountListings,
|
||||||
ROUTES.accountSettings,
|
ROUTES.accountSettings,
|
||||||
ROUTES.notifications,
|
ROUTES.notifications,
|
||||||
ROUTES.accountSecurity,
|
|
||||||
ROUTES.accountHelpContact,
|
ROUTES.accountHelpContact,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -307,6 +306,33 @@ test.describe('Layout konta', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bezpieczenstwo i edycja profilu byly osobnymi stronami powtarzajacymi tresc ustawien.
|
||||||
|
// Teraz sa sekcjami /konto/ustawienia, a stare adresy maja przekierowywac.
|
||||||
|
test('stare adresy profilu i bezpieczenstwa przekierowuja do ustawien', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
for (const path of [ROUTES.accountSecurity, ROUTES.accountProfileEdit]) {
|
||||||
|
await goto(page, path);
|
||||||
|
expect(new URL(page.url()).pathname, `${path} nie przekierowal`).toBe(ROUTES.accountSettings);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ustawienia pokazuja dane podane przy rejestracji i nie dubluja sekcji', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await goto(page, ROUTES.accountSettings);
|
||||||
|
|
||||||
|
// E-mail konta jest wypelniony automatycznie i nie da sie go tu zmienic.
|
||||||
|
const emailInput = page.locator('.settings-card input[disabled]').first();
|
||||||
|
await expect(emailInput).toHaveValue(ADMIN.email);
|
||||||
|
|
||||||
|
// Nick nadany przy zakladaniu konta jest widoczny w formularzu.
|
||||||
|
await expect(page.getByText('polskalokalnie.pl/u/', { exact: false }).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Jezyk komunikacji wystepowal wczesniej dwa razy na jednym ekranie.
|
||||||
|
await expect(page.getByText('Język komunikacji', { exact: true })).toHaveCount(1);
|
||||||
|
// Bezpieczenstwo jest dokladnie jedna sekcja, nie karta + osobna strona.
|
||||||
|
await expect(page.locator('section[aria-label="Bezpieczeństwo"]')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('menu uzytkownika w naglowku ma pozycje ulozone w jednej linii', async ({ page }) => {
|
test('menu uzytkownika w naglowku ma pozycje ulozone w jednej linii', async ({ page }) => {
|
||||||
await login(page);
|
await login(page);
|
||||||
await goto(page, ROUTES.home);
|
await goto(page, ROUTES.home);
|
||||||
|
|||||||
Reference in New Issue
Block a user