feat(#22): manage landlord profile from OIDC

This commit is contained in:
nessar committed 2026-08-17 14:18:15 +02:00
1 parent f02e8a9ba5
commit ecacab0b9f
16 files changed
+661 -3

No files matched your search

@@ -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;
}
}
@@ -38,6 +38,102 @@
>
</mat-radio-group>
</article>
<article class="settings-card landlord-profile" aria-labelledby="profile-heading">
<div class="settings-copy">
<span class="settings-icon" aria-hidden="true"><fa-icon [icon]="faAddressCard" /></span>
<div>
<p class="eyebrow">Bailleur</p>
<h2 id="profile-heading">Profil personnel</h2>
<p>Ces coordonnées sont utilisées dans vos baux et vos quittances.</p>
</div>
</div>
<div class="profile-content">
@if (profileStatus() === 'loading') {
<div class="profile-loading" role="status" aria-live="polite">
<mat-spinner diameter="24" aria-hidden="true" /> Chargement de votre profil…
</div>
} @else if (profileStatus() === 'error') {
<div class="profile-message">
<p class="alert-error" role="alert">{{ profileError() }}</p>
<button mat-stroked-button type="button" (click)="loadProfile()">Réessayer</button>
</div>
} @else {
<form class="profile-form" (ngSubmit)="save()" [attr.aria-busy]="saving()">
@if (saveError()) {
<p class="alert-error" role="alert">{{ saveError() }}</p>
}
@if (saveSuccess()) {
<p class="profile-success" role="status">{{ saveSuccess() }}</p>
}
<fieldset class="busy-fieldset" [disabled]="saving()" [attr.inert]="saving() ? '' : null">
<div class="profile-fields">
<mat-form-field appearance="outline"
><mat-label>Prénom</mat-label
><input
matInput
name="firstName"
[ngModel]="firstName()"
(ngModelChange)="firstName.set($event)"
required
autocomplete="given-name"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Nom</mat-label
><input
matInput
name="lastName"
[ngModel]="lastName()"
(ngModelChange)="lastName.set($event)"
required
autocomplete="family-name"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>E-mail</mat-label
><input
matInput
type="email"
name="email"
[ngModel]="email()"
(ngModelChange)="email.set($event)"
required
autocomplete="email"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Téléphone</mat-label
><input
matInput
type="tel"
name="phone"
[ngModel]="phone()"
(ngModelChange)="phone.set($event)"
required
autocomplete="tel"
/></mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Adresse postale</mat-label
><input
matInput
name="address"
[ngModel]="address()"
(ngModelChange)="address.set($event)"
required
autocomplete="street-address"
/></mat-form-field>
</div>
<div class="profile-actions">
<button mat-flat-button type="submit" class="primary-action">
@if (saving()) {
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
} @else {
Enregistrer le profil
}
</button>
</div>
</fieldset>
</form>
}
</div>
</article>
<article
class="settings-card session-settings"
data-testid="session-settings"
@@ -0,0 +1,101 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { HttpErrorResponse } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { LandlordProfileService } from '../../services/landlord-profile.service';
import { SessionService } from '../../services/session.service';
import { ThemeService } from '../../services/theme.service';
import { SettingsPage } from './settings-page';
describe('SettingsPage', () => {
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');
});
});
@@ -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'
);
}
}