diff --git a/backend/migrations/20260717-landlord-profile-indexes.js b/backend/migrations/20260717-landlord-profile-indexes.js new file mode 100644 index 0000000..4319089 --- /dev/null +++ b/backend/migrations/20260717-landlord-profile-indexes.js @@ -0,0 +1,9 @@ +db.getSiblingDB('lmnp').landlords.createIndex( + { oidcSubject: 1 }, + { name: 'landlords_oidc_subject_unique', unique: true, sparse: true }, +); + +db.getSiblingDB('lmnp').landlords.createIndex( + { email: 1 }, + { name: 'landlords_email_unique', unique: true, sparse: true }, +); diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/SecurityConfig.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/SecurityConfig.java index 91474c5..a85adbd 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/SecurityConfig.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/SecurityConfig.java @@ -23,6 +23,7 @@ public class SecurityConfig { .requestMatchers("/api/tenants", "/api/tenants/**").authenticated() .requestMatchers("/api/leases", "/api/leases/**").authenticated() .requestMatchers("/api/rent-payments", "/api/rent-payments/**").authenticated() + .requestMatchers("/api/landlord-profile").authenticated() .requestMatchers("/api/user").permitAll() .anyRequest().permitAll() ) diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApiExceptionHandler.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApiExceptionHandler.java index 09e24ab..c2c86ec 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApiExceptionHandler.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApiExceptionHandler.java @@ -1,6 +1,7 @@ package fr.nessar.lmnporganizer.backend.controller; import fr.nessar.lmnporganizer.backend.exception.AttachmentTooLargeException; +import fr.nessar.lmnporganizer.backend.exception.EmailAlreadyUsedException; import fr.nessar.lmnporganizer.backend.exception.InvalidPhoneNumberException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -26,6 +27,11 @@ public class ApiExceptionHandler { return error(HttpStatus.BAD_REQUEST, exception.getMessage()); } + @ExceptionHandler(EmailAlreadyUsedException.class) + public ResponseEntity handleDuplicateEmail(EmailAlreadyUsedException exception) { + return error(HttpStatus.CONFLICT, exception.getMessage()); + } + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleInvalidRequest(MethodArgumentNotValidException exception) { String message = exception.getBindingResult().getFieldErrors().stream() diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/LandlordProfileController.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/LandlordProfileController.java new file mode 100644 index 0000000..e767678 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/LandlordProfileController.java @@ -0,0 +1,34 @@ +package fr.nessar.lmnporganizer.backend.controller; + +import fr.nessar.lmnporganizer.backend.dto.LandlordProfileRequest; +import fr.nessar.lmnporganizer.backend.dto.LandlordProfileResponse; +import fr.nessar.lmnporganizer.backend.service.LandlordProfileService; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +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; + +@RestController +@RequestMapping("/api/landlord-profile") +public class LandlordProfileController { + private final LandlordProfileService landlordProfileService; + + public LandlordProfileController(LandlordProfileService landlordProfileService) { + this.landlordProfileService = landlordProfileService; + } + + @GetMapping + public LandlordProfileResponse getProfile(@AuthenticationPrincipal OidcUser principal) { + return landlordProfileService.getProfile(principal); + } + + @PutMapping + public LandlordProfileResponse updateProfile(@AuthenticationPrincipal OidcUser principal, + @Valid @RequestBody LandlordProfileRequest request) { + return landlordProfileService.updateProfile(principal, request); + } +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/LandlordProfileRequest.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/LandlordProfileRequest.java new file mode 100644 index 0000000..3f5e723 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/LandlordProfileRequest.java @@ -0,0 +1,13 @@ +package fr.nessar.lmnporganizer.backend.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record LandlordProfileRequest( + @NotBlank(message = "Le prénom est obligatoire.") String firstName, + @NotBlank(message = "Le nom est obligatoire.") String lastName, + @NotBlank(message = "L'adresse e-mail est obligatoire.") @Email(message = "L'adresse e-mail est invalide.") String email, + @NotBlank(message = "Le numéro de téléphone est obligatoire.") String phone, + @NotBlank(message = "L'adresse postale est obligatoire.") String address +) { +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/LandlordProfileResponse.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/LandlordProfileResponse.java new file mode 100644 index 0000000..41585ae --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/LandlordProfileResponse.java @@ -0,0 +1,4 @@ +package fr.nessar.lmnporganizer.backend.dto; + +public record LandlordProfileResponse(String firstName, String lastName, String email, String phone, String address) { +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/exception/EmailAlreadyUsedException.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/exception/EmailAlreadyUsedException.java new file mode 100644 index 0000000..4648d2b --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/exception/EmailAlreadyUsedException.java @@ -0,0 +1,7 @@ +package fr.nessar.lmnporganizer.backend.exception; + +public class EmailAlreadyUsedException extends RuntimeException { + public EmailAlreadyUsedException() { + super("Cette adresse e-mail est déjà utilisée."); + } +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Landlord.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Landlord.java index 31f9365..90a6a3a 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Landlord.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Landlord.java @@ -7,6 +7,7 @@ import org.springframework.data.mongodb.core.mapping.Document; public class Landlord { @Id private String id; + private String oidcSubject; private String firstName; private String lastName; private String email; @@ -24,6 +25,12 @@ public class Landlord { this.address = address; } + public Landlord(String id, String oidcSubject, String firstName, String lastName, String email, String phone, + String address) { + this(id, firstName, lastName, email, phone, address); + this.oidcSubject = oidcSubject; + } + public String getId() { return id; } @@ -32,6 +39,14 @@ public class Landlord { this.id = id; } + public String getOidcSubject() { + return oidcSubject; + } + + public void setOidcSubject(String oidcSubject) { + this.oidcSubject = oidcSubject; + } + public String getFirstName() { return firstName; } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/LandlordRepository.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/LandlordRepository.java index 32ac41e..fa8cd34 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/LandlordRepository.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/LandlordRepository.java @@ -4,6 +4,11 @@ import fr.nessar.lmnporganizer.backend.model.Landlord; import org.springframework.data.mongodb.repository.MongoRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface LandlordRepository extends MongoRepository { + Optional findByOidcSubject(String oidcSubject); + + Optional findByEmail(String email); } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LandlordProfileService.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LandlordProfileService.java new file mode 100644 index 0000000..39a9045 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LandlordProfileService.java @@ -0,0 +1,67 @@ +package fr.nessar.lmnporganizer.backend.service; + +import fr.nessar.lmnporganizer.backend.dto.LandlordProfileRequest; +import fr.nessar.lmnporganizer.backend.dto.LandlordProfileResponse; +import fr.nessar.lmnporganizer.backend.exception.EmailAlreadyUsedException; +import fr.nessar.lmnporganizer.backend.model.Landlord; +import fr.nessar.lmnporganizer.backend.repository.LandlordRepository; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Service; + +@Service +public class LandlordProfileService { + private final LandlordRepository landlordRepository; + private final PhoneNumberNormalizer phoneNumberNormalizer; + + public LandlordProfileService(LandlordRepository landlordRepository, PhoneNumberNormalizer phoneNumberNormalizer) { + this.landlordRepository = landlordRepository; + this.phoneNumberNormalizer = phoneNumberNormalizer; + } + + public LandlordProfileResponse getProfile(OidcUser principal) { + Landlord landlord = landlordRepository.findByOidcSubject(principal.getSubject()).orElseGet(() -> + new Landlord(null, principal.getSubject(), claim(principal, "given_name"), + claim(principal, "family_name"), principal.getClaimAsString("email"), "", "")); + return response(landlord); + } + + public LandlordProfileResponse updateProfile(OidcUser principal, LandlordProfileRequest request) { + Landlord landlord = landlordRepository.findByOidcSubject(principal.getSubject()) + .orElseGet(() -> new Landlord(null, principal.getSubject(), "", "", "", "", "")); + String email = request.email().trim(); + landlordRepository.findByEmail(email) + .filter(existing -> !existing.getId().equals(landlord.getId())) + .ifPresent(existing -> { + throw new EmailAlreadyUsedException(); + }); + landlord.setFirstName(request.firstName().trim()); + landlord.setLastName(request.lastName().trim()); + landlord.setEmail(email); + landlord.setPhone(phoneNumberNormalizer.normalize(request.phone())); + landlord.setAddress(request.address().trim()); + return response(save(landlord)); + } + + private Landlord save(Landlord landlord) { + try { + return landlordRepository.save(landlord); + } catch (DuplicateKeyException exception) { + throw new EmailAlreadyUsedException(); + } + } + + private String claim(OidcUser principal, String name) { + String value = principal.getClaimAsString(name); + return value == null ? "" : value; + } + + private LandlordProfileResponse response(Landlord landlord) { + return new LandlordProfileResponse(value(landlord.getFirstName()), value(landlord.getLastName()), + value(landlord.getEmail()), value(landlord.getPhone()), value(landlord.getAddress())); + } + + private String value(String value) { + return value == null ? "" : value; + } +} diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LandlordProfileControllerTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LandlordProfileControllerTest.java new file mode 100644 index 0000000..4f55cdf --- /dev/null +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LandlordProfileControllerTest.java @@ -0,0 +1,119 @@ +package fr.nessar.lmnporganizer.backend.controller; + +import fr.nessar.lmnporganizer.backend.model.Landlord; +import fr.nessar.lmnporganizer.backend.repository.LandlordRepository; +import fr.nessar.lmnporganizer.backend.SecurityConfig; +import fr.nessar.lmnporganizer.backend.service.LandlordProfileService; +import fr.nessar.lmnporganizer.backend.service.PhoneNumberNormalizer; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(LandlordProfileController.class) +@Import({ApiExceptionHandler.class, LandlordProfileService.class, PhoneNumberNormalizer.class, SecurityConfig.class}) +class LandlordProfileControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private LandlordRepository landlordRepository; + + @Test + void whenFirstProfileRead_thenOidcClaimsPrefillTheNewProfile() throws Exception { + when(landlordRepository.findByOidcSubject("subject-1")).thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/landlord-profile") + .with(oidcLogin().idToken(token -> token + .claim("sub", "subject-1") + .claim("given_name", "Camille") + .claim("family_name", "Martin") + .claim("email", "camille@example.com")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.firstName").value("Camille")) + .andExpect(jsonPath("$.lastName").value("Martin")) + .andExpect(jsonPath("$.email").value("camille@example.com")); + + verify(landlordRepository).findByOidcSubject("subject-1"); + verify(landlordRepository, never()).save(any(Landlord.class)); + } + + @Test + void whenProfileRequestIsIncomplete_thenReturnsHelpfulValidationError() throws Exception { + mockMvc.perform(put("/api/landlord-profile") + .with(oidcLogin().idToken(token -> token.claim("sub", "subject-1"))) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firstName\":\"\",\"lastName\":\"Martin\",\"email\":\"camille@example.com\",\"phone\":\"06 00 00 00 00\",\"address\":\"1 rue de Paris\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("Le prénom est obligatoire.")); + } + + @Test + void whenProfileIsUpdated_thenOnlyTheConnectedAccountsProfileChanges() throws Exception { + Landlord profile = new Landlord("profile-1", "subject-1", "Camille", "Martin", "camille@example.com", "+33600000000", "1 rue de Paris"); + Landlord otherProfile = new Landlord("profile-2", "subject-2", "Alex", "Durand", "alex@example.com", "+33600000001", "2 rue de Paris"); + when(landlordRepository.findByOidcSubject("subject-1")).thenReturn(Optional.of(profile)); + when(landlordRepository.findByOidcSubject("subject-2")).thenReturn(Optional.of(otherProfile)); + when(landlordRepository.findByEmail("camille.renee@example.com")).thenReturn(Optional.empty()); + when(landlordRepository.save(any(Landlord.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + mockMvc.perform(put("/api/landlord-profile") + .with(oidcLogin().idToken(token -> token.claim("sub", "subject-1"))) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firstName\":\"Camille\",\"lastName\":\"Renée\",\"email\":\"camille.renee@example.com\",\"phone\":\"06 00 00 00 00\",\"address\":\"2 rue de Lyon\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lastName").value("Renée")) + .andExpect(jsonPath("$.email").value("camille.renee@example.com")) + .andExpect(jsonPath("$.phone").value("+33600000000")) + .andExpect(jsonPath("$.address").value("2 rue de Lyon")); + + mockMvc.perform(get("/api/landlord-profile") + .with(oidcLogin().idToken(token -> token.claim("sub", "subject-2")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.firstName").value("Alex")) + .andExpect(jsonPath("$.lastName").value("Durand")) + .andExpect(jsonPath("$.email").value("alex@example.com")); + + verify(landlordRepository).findByOidcSubject("subject-1"); + verify(landlordRepository).findByOidcSubject("subject-2"); + verify(landlordRepository, never()).findById(anyString()); + } + + @Test + void whenAnotherAccountUsesTheEmail_thenProfileUpdateIsRejected() throws Exception { + Landlord profile = new Landlord("profile-1", "subject-1", "Camille", "Martin", "camille@example.com", "+33600000000", "1 rue de Paris"); + Landlord otherProfile = new Landlord("profile-2", "subject-2", "Alex", "Durand", "alex@example.com", "+33600000001", "2 rue de Paris"); + when(landlordRepository.findByOidcSubject("subject-1")).thenReturn(Optional.of(profile)); + when(landlordRepository.findByEmail("alex@example.com")).thenReturn(Optional.of(otherProfile)); + + mockMvc.perform(put("/api/landlord-profile") + .with(oidcLogin().idToken(token -> token.claim("sub", "subject-1"))) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firstName\":\"Camille\",\"lastName\":\"Martin\",\"email\":\"alex@example.com\",\"phone\":\"06 00 00 00 00\",\"address\":\"1 rue de Paris\"}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.message").value("Cette adresse e-mail est déjà utilisée.")); + } + + @Test + void whenUnauthenticated_thenProfileCannotBeRead() throws Exception { + mockMvc.perform(get("/api/landlord-profile")) + .andExpect(status().isUnauthorized()); + } +} diff --git a/frontend/src/app/components/settings-page/settings-page.css b/frontend/src/app/components/settings-page/settings-page.css index e16949d..b3bd05c 100644 --- a/frontend/src/app/components/settings-page/settings-page.css +++ b/frontend/src/app/components/settings-page/settings-page.css @@ -71,6 +71,46 @@ .session-settings { margin-top: 16px; } +.landlord-profile { + margin-top: 16px; +} +.profile-content { + padding: 22px; +} +.profile-loading, +.profile-message { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + min-height: 72px; +} +.profile-message { + align-items: flex-start; + flex-direction: column; +} +.profile-message .alert-error, +.profile-form .alert-error, +.profile-success { + margin: 0 0 16px; +} +.profile-success { + color: var(--accent-strong, #0a5f59); + font-weight: 600; +} +.profile-fields { + display: grid; + gap: 4px 12px; +} +.profile-fields mat-form-field { + width: 100%; +} +.profile-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + margin-top: 8px; +} .session-action { display: flex; flex-direction: column; @@ -112,4 +152,10 @@ align-items: flex-start; justify-content: center; } + .profile-fields { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .profile-fields .full-field { + grid-column: 1 / -1; + } } diff --git a/frontend/src/app/components/settings-page/settings-page.html b/frontend/src/app/components/settings-page/settings-page.html index e19801b..ddbcd70 100644 --- a/frontend/src/app/components/settings-page/settings-page.html +++ b/frontend/src/app/components/settings-page/settings-page.html @@ -38,6 +38,102 @@ > +
+
+ +
+

Bailleur

+

Profil personnel

+

Ces coordonnées sont utilisées dans vos baux et vos quittances.

+
+
+
+ @if (profileStatus() === 'loading') { +
+
+ } @else if (profileStatus() === 'error') { +
+ + +
+ } @else { +
+ @if (saveError()) { + + } + @if (saveSuccess()) { +

{{ saveSuccess() }}

+ } +
+
+ Prénom + Nom + E-mail + Téléphone + Adresse postale +
+
+ +
+
+
+ } +
+
{ + const profile = { + getProfile: vi.fn(), + updateProfile: vi.fn(), + }; + const session = { + username: signal('Camille Martin'), + email: signal('camille@example.com'), + }; + const theme = { + preference: signal('system'), + setPreference: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + profile.getProfile.mockReturnValue( + of({ + firstName: 'Camille', + lastName: 'Martin', + email: 'camille@example.com', + phone: '+33600000000', + address: '1 rue de Paris', + }), + ); + TestBed.configureTestingModule({ + providers: [ + { provide: LandlordProfileService, useValue: profile }, + { provide: SessionService, useValue: session }, + { provide: ThemeService, useValue: theme }, + ], + }); + }); + + it('prefills the landlord profile from the connected account', async () => { + const fixture = TestBed.createComponent(SettingsPage); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('input[name=firstName]').value).toBe('Camille'); + expect(fixture.nativeElement.querySelector('input[name=email]').value).toBe( + 'camille@example.com', + ); + expect(fixture.nativeElement.querySelector('input[name=address]').value).toBe('1 rue de Paris'); + }); + + it('shows a useful error instead of saving an incomplete profile', () => { + const fixture = TestBed.createComponent(SettingsPage); + fixture.detectChanges(); + const component = fixture.componentInstance; + component.phone.set(''); + + component.save(); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain( + 'Veuillez renseigner tous les champs obligatoires.', + ); + expect(profile.updateProfile).not.toHaveBeenCalled(); + }); + + it('keeps the entered values and explains a rejected profile save', async () => { + profile.updateProfile.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + error: { message: 'Cette adresse e-mail est déjà utilisée.' }, + status: 409, + }), + ), + ); + const fixture = TestBed.createComponent(SettingsPage); + fixture.detectChanges(); + const component = fixture.componentInstance; + component.email.set('alex@example.com'); + + component.save(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(profile.updateProfile).toHaveBeenCalledWith({ + firstName: 'Camille', + lastName: 'Martin', + email: 'alex@example.com', + phone: '+33600000000', + address: '1 rue de Paris', + }); + expect(fixture.nativeElement.textContent).toContain('Cette adresse e-mail est déjà utilisée.'); + expect(fixture.nativeElement.querySelector('input[name=email]').value).toBe('alex@example.com'); + }); +}); diff --git a/frontend/src/app/components/settings-page/settings-page.ts b/frontend/src/app/components/settings-page/settings-page.ts index 2a6e21a..0ae95cd 100644 --- a/frontend/src/app/components/settings-page/settings-page.ts +++ b/frontend/src/app/components/settings-page/settings-page.ts @@ -1,24 +1,135 @@ -import { Component, inject } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; -import { faMoon, faUser } from '@fortawesome/free-solid-svg-icons'; +import { faAddressCard, faMoon, faUser } from '@fortawesome/free-solid-svg-icons'; import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatRadioModule } from '@angular/material/radio'; +import { FormsModule } from '@angular/forms'; +import { HttpErrorResponse } from '@angular/common/http'; +import { LandlordProfile, LandlordProfileService } from '../../services/landlord-profile.service'; import { SessionService } from '../../services/session.service'; import { ThemeService } from '../../services/theme.service'; @Component({ selector: 'app-settings-page', - imports: [FontAwesomeModule, MatButtonModule, MatRadioModule], + imports: [ + FontAwesomeModule, + FormsModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatProgressSpinnerModule, + MatRadioModule, + ], templateUrl: './settings-page.html', styleUrl: './settings-page.css', }) export class SettingsPage { readonly faMoon = faMoon; readonly faUser = faUser; + readonly faAddressCard = faAddressCard; readonly session = inject(SessionService); readonly theme = inject(ThemeService); + private readonly landlordProfile = inject(LandlordProfileService); + + readonly profileStatus = signal<'loading' | 'ready' | 'error'>('loading'); + readonly saving = signal(false); + readonly firstName = signal(''); + readonly lastName = signal(''); + readonly email = signal(''); + readonly phone = signal(''); + readonly address = signal(''); + readonly profileError = signal(''); + readonly saveError = signal(''); + readonly saveSuccess = signal(''); + + constructor() { + this.loadProfile(); + } logout(): void { window.location.href = '/logout'; } + + loadProfile(): void { + this.profileStatus.set('loading'); + this.profileError.set(''); + this.landlordProfile.getProfile().subscribe({ + next: (profile) => { + this.applyProfile(profile); + this.profileStatus.set('ready'); + }, + error: () => { + this.profileStatus.set('error'); + this.profileError.set('Impossible de charger votre profil bailleur. Réessayez.'); + }, + }); + } + + save(): void { + if (this.saving()) return; + const profile = { + firstName: this.firstName().trim(), + lastName: this.lastName().trim(), + email: this.email().trim(), + phone: this.phone().trim(), + address: this.address().trim(), + }; + this.saveSuccess.set(''); + this.saveError.set(this.validationError(profile)); + if (this.saveError()) return; + + this.saving.set(true); + this.landlordProfile.updateProfile(profile).subscribe({ + next: (savedProfile) => { + this.applyProfile(savedProfile); + this.saveSuccess.set('Profil bailleur enregistré.'); + this.saving.set(false); + }, + error: (error: unknown) => { + this.saveError.set(this.saveErrorMessage(error)); + this.saving.set(false); + }, + }); + } + + private validationError(profile: LandlordProfile): string { + if (Object.values(profile).some((value) => !value)) { + return 'Veuillez renseigner tous les champs obligatoires.'; + } + if ( + !profile.email.includes('@') || + profile.email.startsWith('@') || + profile.email.endsWith('@') + ) { + return 'Saisissez une adresse e-mail valide.'; + } + return ''; + } + + private applyProfile(profile: LandlordProfile): void { + this.firstName.set(profile.firstName); + this.lastName.set(profile.lastName); + this.email.set(profile.email); + this.phone.set(profile.phone); + this.address.set(profile.address); + } + + private saveErrorMessage(error: unknown): string { + if (error instanceof HttpErrorResponse && this.hasMessage(error.error)) { + return error.error.message; + } + return 'Impossible d’enregistrer votre profil bailleur. Réessayez.'; + } + + private hasMessage(value: unknown): value is { message: string } { + return ( + typeof value === 'object' && + value !== null && + 'message' in value && + typeof value.message === 'string' + ); + } } diff --git a/frontend/src/app/services/landlord-profile.service.ts b/frontend/src/app/services/landlord-profile.service.ts new file mode 100644 index 0000000..a5aa84d --- /dev/null +++ b/frontend/src/app/services/landlord-profile.service.ts @@ -0,0 +1,24 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +export interface LandlordProfile { + firstName: string; + lastName: string; + email: string; + phone: string; + address: string; +} + +@Injectable({ providedIn: 'root' }) +export class LandlordProfileService { + private readonly http = inject(HttpClient); + + getProfile(): Observable { + return this.http.get('/api/landlord-profile'); + } + + updateProfile(profile: LandlordProfile): Observable { + return this.http.put('/api/landlord-profile', profile); + } +}