zmiany w ustawieniach

This commit is contained in:
2026-08-12 17:03:22 +02:00
parent 1e361bde93
commit fbb7ab2d90
32 changed files with 2784 additions and 1194 deletions
@@ -4,12 +4,16 @@ import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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.PostMapping;
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.RequestParam;
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.ResetPasswordRequest;
import pl.polskalokalnie.auth.dto.AccountRequests.TokenRequest;
@@ -98,4 +102,30 @@ public class AuthController {
public UserResponse updateMe(Authentication authentication, @Valid @RequestBody UpdateProfileRequest 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.BlockedEmailRepository;
import pl.polskalokalnie.user.ContactPreference;
import pl.polskalokalnie.user.NickService;
import pl.polskalokalnie.user.PreferredLanguage;
import pl.polskalokalnie.user.Role;
import pl.polskalokalnie.user.UserRepository;
import pl.polskalokalnie.user.UserSettingsService;
@Service
public class AuthService {
@@ -46,6 +48,8 @@ public class AuthService {
private final AuthTokenRepository authTokenRepository;
private final AccountEmailService accountEmailService;
private final PhoneVerificationService phoneVerificationService;
private final NickService nickService;
private final UserSettingsService userSettingsService;
public AuthService(
UserRepository userRepository,
@@ -56,7 +60,9 @@ public class AuthService {
LeadSyncService leadSyncService,
AuthTokenRepository authTokenRepository,
AccountEmailService accountEmailService,
PhoneVerificationService phoneVerificationService
PhoneVerificationService phoneVerificationService,
NickService nickService,
UserSettingsService userSettingsService
) {
this.userRepository = userRepository;
this.blockedEmailRepository = blockedEmailRepository;
@@ -67,6 +73,8 @@ public class AuthService {
this.authTokenRepository = authTokenRepository;
this.accountEmailService = accountEmailService;
this.phoneVerificationService = phoneVerificationService;
this.nickService = nickService;
this.userSettingsService = userSettingsService;
}
@Transactional
@@ -84,6 +92,9 @@ public class AuthService {
AppUser user = new AppUser();
user.setEmail(email);
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.setRole(Role.USER);
user.setProvider(AuthProvider.LOCAL);
@@ -103,13 +114,15 @@ public class AuthService {
String token = issueToken(saved.getEmail(), TokenPurpose.ACTIVATION, ACTIVATION_TTL_MINUTES);
accountEmailService.sendActivation(saved.getEmail(), saved.getFullName(), token);
// Gdy podano telefon - od razu wysylamy kod SMS do potwierdzenia numeru.
boolean phoneVerificationRequired = phone != null;
if (phoneVerificationRequired) {
phoneVerificationService.sendOtp(saved.getEmail(), phone);
// Gdy podano telefon - od razu wysylamy kod SMS do potwierdzenia numeru. Konto jest juz
// zalozone, wiec awaria bramki nie moze przerwac rejestracji; zglaszamy tylko, ze kodu nie ma
// co wpisywac, a uzytkownik potwierdzi numer pozniej w ustawieniach konta.
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) {
@@ -187,6 +200,10 @@ public class AuthService {
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) {
String normalized = normalizeEmail(email);
AppUser user = userRepository.findByEmailIgnoreCase(normalized)
@@ -194,7 +211,17 @@ public class AuthService {
if (user.getPhone() == null || user.getPhone().isBlank()) {
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 ---
@@ -252,6 +279,7 @@ public class AuthService {
AppUser created = new AppUser();
created.setEmail(email);
created.setFullName(name);
created.setNick(nickService.generateFromName(name, email, null));
created.setRole(Role.USER);
created.setProvider(provider);
// Logowanie spoleczne nie wymaga rekopisania danych, wiec konto jest od razu zweryfikowane.
@@ -277,10 +305,20 @@ public class AuthService {
AppUser user = userRepository.findByEmailIgnoreCase(email)
.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.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.setContactPreference(request.contactPreference() != null ? request.contactPreference() : ContactPreference.EMAIL_AND_PHONE);
user.setPreferredLanguage(request.preferredLanguage() != null ? request.preferredLanguage() : PreferredLanguage.PL);
@@ -299,6 +337,58 @@ public class AuthService {
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) {
return new AuthResponse(jwtService.generateToken(user), UserResponse.from(user));
}
@@ -307,6 +397,17 @@ public class AuthService {
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) {
return switch (provider) {
case GOOGLE -> "demo.google@gmail.com";
@@ -32,10 +32,21 @@ public class PhoneOtp {
@Column(nullable = false)
private int attempts = 0;
// Znacznik ostatniej wysylki - pilnuje odstepu miedzy kolejnymi SMS-ami (kazdy kosztuje).
private Instant lastSentAt;
public Long getId() {
return id;
}
public Instant getLastSentAt() {
return lastSentAt;
}
public void setLastSentAt(Instant lastSentAt) {
this.lastSentAt = lastSentAt;
}
public String getUserEmail() {
return userEmail;
}
@@ -1,6 +1,7 @@
package pl.polskalokalnie.auth;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import org.slf4j.Logger;
@@ -26,6 +27,7 @@ public class PhoneVerificationService {
private static final SecureRandom RANDOM = new SecureRandom();
private static final int MAX_ATTEMPTS = 5;
private static final int TTL_MINUTES = 10;
private static final int RESEND_COOLDOWN_SECONDS = 60;
private final PhoneOtpRepository otpRepository;
private final SmsSender smsSender;
@@ -42,31 +44,74 @@ public class PhoneVerificationService {
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
public void sendOtp(String email, String phone) {
public OtpDispatch sendOtp(String email, String phone) {
if (phone == null || phone.isBlank()) {
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);
// 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.setPhone(phone.trim());
otp.setCode(code);
otp.setExpiresAt(Instant.now().plus(TTL_MINUTES, ChronoUnit.MINUTES));
otp.setAttempts(0);
otpRepository.save(otp);
SmsGatewayConfig sms = mailConfigService.getSms();
boolean configured = sms.isEnabled() && sms.getApiKey() != null && !sms.getApiKey().isBlank();
String message = "Polska Lokalnie: Twoj kod weryfikacyjny to " + code + ". Wazny 10 minut.";
boolean configured = sms.isEnabled() && sms.getApiKey() != null && !sms.getApiKey().isBlank()
&& sms.getEndpointUrl() != null && !sms.getEndpointUrl().isBlank();
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);
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);
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) {
}
/** 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(
@NotBlank String fullName,
// Pusty nick oznacza "nie zmieniaj" - dotychczasowa nazwa uzytkownika zostaje.
String nick,
String phone,
String birthDate,
String address,
@@ -13,6 +13,7 @@ public record UserResponse(
Long id,
String email,
String fullName,
String nick,
Role role,
AuthProvider provider,
AccountType accountType,
@@ -33,6 +34,7 @@ public record UserResponse(
user.getId(),
user.getEmail(),
user.getFullName(),
user.getNick(),
user.getRole(),
user.getProvider(),
user.getAccountType(),
@@ -19,6 +19,7 @@ import pl.polskalokalnie.promotion.PromotionPlan;
import pl.polskalokalnie.promotion.PromotionPlanRepository;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.AuthProvider;
import pl.polskalokalnie.user.NickService;
import pl.polskalokalnie.user.Role;
import pl.polskalokalnie.user.UserRepository;
@@ -31,6 +32,7 @@ public class DataSeeder implements CommandLineRunner {
private final PromotionPlanRepository promotionPlanRepository;
private final PromotionPackageRepository promotionPackageRepository;
private final PasswordEncoder passwordEncoder;
private final NickService nickService;
private final String adminEmail;
private final String adminPassword;
private final String adminName;
@@ -42,6 +44,7 @@ public class DataSeeder implements CommandLineRunner {
PromotionPlanRepository promotionPlanRepository,
PromotionPackageRepository promotionPackageRepository,
PasswordEncoder passwordEncoder,
NickService nickService,
@Value("${app.admin.email:admin@mieszko.pl}") String adminEmail,
@Value("${app.admin.password:Admin123!}") String adminPassword,
@Value("${app.admin.name:Administrator Polska Lokalnie}") String adminName
@@ -52,6 +55,7 @@ public class DataSeeder implements CommandLineRunner {
this.promotionPlanRepository = promotionPlanRepository;
this.promotionPackageRepository = promotionPackageRepository;
this.passwordEncoder = passwordEncoder;
this.nickService = nickService;
this.adminEmail = adminEmail;
this.adminPassword = adminPassword;
this.adminName = adminName;
@@ -138,6 +142,7 @@ public class DataSeeder implements CommandLineRunner {
AppUser admin = new AppUser();
admin.setEmail(adminEmail.toLowerCase());
admin.setFullName(adminName);
admin.setNick(nickService.generateUnique(adminEmail.split("@")[0]));
admin.setPasswordHash(passwordEncoder.encode(adminPassword));
admin.setRole(Role.ADMIN);
admin.setProvider(AuthProvider.LOCAL);
@@ -41,6 +41,30 @@ public class SchemaFixer {
jdbcTemplate.execute(
"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(
"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
? config.getTimeoutSeconds() : 10;
String recipient = normalizePhone(phone);
if (recipient.isBlank()) {
return SendResult.failure("Pusty numer telefonu odbiorcy");
}
ObjectNode payload = objectMapper.createObjectNode();
payload.put("api_key", config.getApiKey());
payload.put("to", phone == null ? "" : phone.trim());
payload.put("to", recipient);
payload.put("message", message == null ? "" : message);
String creator = config.getCreator() == null || config.getCreator().isBlank() ? "API" : config.getCreator();
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) {
if (body == null || body.isBlank()) {
return null;
@@ -27,6 +27,11 @@ public class AppUser {
@Column(length = 120)
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.
@Column(length = 100)
private String passwordHash;
@@ -113,6 +118,14 @@ public class AppUser {
this.fullName = fullName;
}
public String getNick() {
return nick;
}
public void setNick(String nick) {
this.nick = nick;
}
public String getPasswordHash() {
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 ListingRepository listingRepository;
private final UserSettingsRepository userSettingsRepository;
public PublicProfileController(UserRepository userRepository, ListingRepository listingRepository) {
public PublicProfileController(UserRepository userRepository, ListingRepository listingRepository,
UserSettingsRepository userSettingsRepository) {
this.userRepository = userRepository;
this.listingRepository = listingRepository;
this.userSettingsRepository = userSettingsRepository;
}
@GetMapping("/{id}/profile")
public PublicProfileResponse profile(@PathVariable Long id) {
AppUser user = userRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono takiego użytkownika"));
return toResponse(userRepository.findById(id)
.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.of()
: listingRepository.findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(user.getEmail(), ListingStatus.APPROVED);
// Opis i zdjecie profilowe uzytkownik ustawia w /konto/ustawienia; brak wiersza = nic nie ustawil.
UserSettings settings = userSettingsRepository.findByUserId(user.getId()).orElse(null);
return new PublicProfileResponse(
user.getId(),
user.getFullName(),
user.getNick(),
settings == null ? null : settings.getBio(),
settings == null ? null : settings.getAvatarImage(),
user.getAccountType(),
user.isVerified(),
user.getCreatedAt(),
@@ -11,6 +11,10 @@ import java.util.List;
public record PublicProfileResponse(
Long id,
String fullName,
String nick,
// Krotki opis "o mnie" z ustawien konta - pole opcjonalne, moze byc null.
String bio,
String avatarImage,
AccountType accountType,
boolean verified,
Instant memberSince,
@@ -9,6 +9,10 @@ public interface UserRepository extends JpaRepository<AppUser, Long> {
boolean existsByEmailIgnoreCase(String email);
Optional<AppUser> findByNickIgnoreCase(String nick);
boolean existsByNickIgnoreCase(String nick);
Optional<AppUser> findFirstByRole(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
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -170,7 +170,8 @@ export function PhoneOtpModal({ email, phone, onClose, onVerified }: {
try {
await resendPhoneOtp(email);
setInfo('Wysłaliśmy nowy kod SMS.');
setCooldown(30);
// Zgodne z odstepem wymuszanym przez backend (RESEND_COOLDOWN_SECONDS).
setCooldown(60);
} catch (e) {
setError(errMsg(e));
}
+83 -7
View File
@@ -7,10 +7,45 @@ export type AccountType = 'PERSONAL' | 'COMPANY';
export type ContactPreference = 'EMAIL' | 'PHONE' | 'EMAIL_AND_PHONE';
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 = {
id: number;
email: string;
fullName: string;
nick: string | null;
role: Role;
provider: AuthProviderName;
accountType: AccountType;
@@ -52,11 +87,29 @@ type AuthContextValue = {
resetPassword: (token: string, password: string) => Promise<void>;
verifyPhone: (email: string, code: 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>;
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 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 }) });
}, []);
const updateProfile = useCallback(
async (fullName: string, phone?: string, birthDate?: string, address?: string, contactPreference?: ContactPreference, preferredLanguage?: PreferredLanguage) => {
const updateProfile = useCallback(async (profile: ProfileUpdate) => {
const updated = await apiFetch<AuthUser>('/auth/me', {
method: 'PUT',
body: JSON.stringify({ fullName, phone, birthDate, address, contactPreference, preferredLanguage }),
body: JSON.stringify(profile),
});
setUser(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,
activateAccount, resendActivation, forgotPassword, resetPassword, verifyPhone, resendPhoneOtp,
updateProfile, refreshUser, logout,
updateProfile, changePassword, deleteAccount, loadSettings, saveSettings, refreshUser, logout,
}),
[user, token, loading, login, register, socialLogin,
activateAccount, resendActivation, forgotPassword, resetPassword, verifyPhone, resendPhoneOtp,
updateProfile, refreshUser, logout],
updateProfile, changePassword, deleteAccount, loadSettings, saveSettings, refreshUser, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+8
View File
@@ -39,6 +39,8 @@ export const ROUTES = {
admin: '/admin',
listingDetail: '/oferta/:id',
publicProfile: '/profil/:id',
// Ten sam profil pod nazwą użytkownika - adres pokazywany w ustawieniach konta.
publicProfileByNick: '/u/:nick',
} as const;
export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES];
@@ -58,6 +60,12 @@ export function listingEditPath(id: number): string {
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.
export function negotiationPath(offerId?: number | null): string {
return offerId ? `${ROUTES.negotiation}/${offerId}` : ROUTES.negotiation;
+280 -4
View File
@@ -21712,10 +21712,6 @@ svg {
grid-template-columns: 1fr;
}
.input-action-row {
grid-template-columns: minmax(0, 1fr) 106px;
}
.phone-row {
grid-template-columns: 132px minmax(0, 1fr) auto;
}
@@ -29892,3 +29888,283 @@ a.listing-detail-back {
font-weight: 800;
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;
}
}
+27 -1
View File
@@ -262,7 +262,6 @@ test.describe('Layout konta', () => {
ROUTES.accountListings,
ROUTES.accountSettings,
ROUTES.notifications,
ROUTES.accountSecurity,
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 }) => {
await login(page);
await goto(page, ROUTES.home);