feat(frontend): require landlord profile completion

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 983a080cc7
commit 12316b8604
18 files changed
+838 -217

No files matched your search

@@ -90,7 +90,6 @@
flex-direction: column;
}
.profile-message .alert-error,
.profile-form .alert-error,
.profile-success {
margin: 0 0 16px;
}
@@ -98,19 +97,6 @@
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;
@@ -152,10 +138,4 @@
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;
}
}
@@ -1,99 +1,94 @@
<section class="page-section settings-page">
<header class="page-heading">
<div>
<p class="eyebrow">Préférences</p>
<h1>Réglages</h1>
<p>Adaptez LMNP Organizer à votre façon de travailler.</p>
</div>
</header>
<article class="settings-card" aria-labelledby="appearance-heading">
<div class="settings-copy">
<span class="settings-icon" aria-hidden="true"><fa-icon [icon]="faMoon" /></span>
<div>
<p class="eyebrow">Interface</p>
<h2 id="appearance-heading">Apparence</h2>
<p>Le réglage est mémorisé uniquement sur cet appareil.</p>
</div>
</div>
<mat-radio-group class="theme-options" aria-label="Thème de l'application" [value]="theme.preference()"
(change)="theme.setPreference($event.value)">
<mat-radio-button value="light"><span class="theme-option-copy"><strong>Clair</strong><small>Une interface
lumineuse et douce.</small></span></mat-radio-button>
<mat-radio-button value="dark"><span class="theme-option-copy"><strong>Sombre</strong><small>Un contraste
réduit en faible lumière.</small></span></mat-radio-button>
<mat-radio-button value="system"><span class="theme-option-copy"><strong>Système</strong><small>Suit
automatiquement votre appareil.</small></span></mat-radio-button>
</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" [formRoot]="profileForm" (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 id="firstName"
[formField]="profileForm.firstName" autocomplete="given-name" /></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Nom</mat-label><input matInput id="lastName"
[formField]="profileForm.lastName" autocomplete="family-name" /></mat-form-field>
<mat-form-field appearance="outline"><mat-label>E-mail</mat-label><input matInput type="email"
id="email" [formField]="profileForm.email" autocomplete="email" /></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Téléphone</mat-label><input matInput type="tel"
id="phone" [formField]="profileForm.phone" autocomplete="tel" /></mat-form-field>
<mat-form-field appearance="outline" class="full-field"><mat-label>Adresse
postale</mat-label><input matInput id="address" [formField]="profileForm.address"
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" aria-labelledby="session-heading">
<div class="settings-copy">
<span class="settings-icon" aria-hidden="true"><fa-icon [icon]="faUser" /></span>
<div>
<p class="eyebrow">Compte</p>
<h2 id="session-heading">Session</h2>
<p>Gérez la session ouverte sur cet appareil.</p>
</div>
</div>
<div class="session-action">
<div>
<strong>{{ session.username() }}</strong><span>{{ session.email() }}</span>
</div>
<button mat-stroked-button type="button" (click)="logout()">Déconnexion</button>
</div>
</article>
</section>
<header class="page-heading">
<div>
<p class="eyebrow">Préférences</p>
<h1>Réglages</h1>
<p>Adaptez LMNP Organizer à votre façon de travailler.</p>
</div>
</header>
<article class="settings-card" aria-labelledby="appearance-heading">
<div class="settings-copy">
<span class="settings-icon" aria-hidden="true"><fa-icon [icon]="faMoon" /></span>
<div>
<p class="eyebrow">Interface</p>
<h2 id="appearance-heading">Apparence</h2>
<p>Le réglage est mémorisé uniquement sur cet appareil.</p>
</div>
</div>
<mat-radio-group
class="theme-options"
aria-label="Thème de l'application"
[value]="theme.preference()"
(change)="theme.setPreference($event.value)"
>
<mat-radio-button value="light"
><span class="theme-option-copy"
><strong>Clair</strong><small>Une interface lumineuse et douce.</small></span
></mat-radio-button
>
<mat-radio-button value="dark"
><span class="theme-option-copy"
><strong>Sombre</strong><small>Un contraste réduit en faible lumière.</small></span
></mat-radio-button
>
<mat-radio-button value="system"
><span class="theme-option-copy"
><strong>Système</strong><small>Suit automatiquement votre appareil.</small></span
></mat-radio-button
>
</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 {
@if (saveSuccess()) {
<p class="profile-success" role="status">{{ saveSuccess() }}</p>
}
<app-landlord-profile-form
[profile]="profileModel()"
[saving]="saving()"
[saveError]="saveError()"
(saveRequested)="save($event)"
/>
}
</div>
</article>
<article
class="settings-card session-settings"
data-testid="session-settings"
aria-labelledby="session-heading"
>
<div class="settings-copy">
<span class="settings-icon" aria-hidden="true"><fa-icon [icon]="faUser" /></span>
<div>
<p class="eyebrow">Compte</p>
<h2 id="session-heading">Session</h2>
<p>Gérez la session ouverte sur cet appareil.</p>
</div>
</div>
<div class="session-action">
<div>
<strong>{{ session.username() }}</strong
><span>{{ session.email() }}</span>
</div>
<button mat-stroked-button type="button" (click)="logout()">Déconnexion</button>
</div>
</article>
</section>
@@ -48,24 +48,13 @@ describe('SettingsPage', () => {
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('input#firstName').value).toBe('Camille');
expect(fixture.nativeElement.querySelector('input#email').value).toBe('camille@example.com');
expect(fixture.nativeElement.querySelector('input#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.profileModel.update((p) => ({ ...p, phone: '' }));
component.save();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'Veuillez renseigner tous les champs obligatoires.',
expect(fixture.nativeElement.querySelector('input#profile-firstName').value).toBe('Camille');
expect(fixture.nativeElement.querySelector('input#profile-email').value).toBe(
'camille@example.com',
);
expect(fixture.nativeElement.querySelector('input#profile-address').value).toBe(
'1 rue de Paris',
);
expect(profile.updateProfile).not.toHaveBeenCalled();
});
it('keeps the entered values and explains a rejected profile save', async () => {
@@ -80,10 +69,13 @@ describe('SettingsPage', () => {
);
const fixture = TestBed.createComponent(SettingsPage);
fixture.detectChanges();
const component = fixture.componentInstance;
component.profileModel.update((p) => ({ ...p, email: 'alex@example.com' }));
await fixture.whenStable();
const email = fixture.nativeElement.querySelector('input#profile-email') as HTMLInputElement;
email.value = 'alex@example.com';
email.dispatchEvent(new Event('input'));
fixture.detectChanges();
component.save();
fixture.nativeElement.querySelector('button.primary-action').click();
fixture.detectChanges();
await fixture.whenStable();
@@ -95,6 +87,8 @@ describe('SettingsPage', () => {
address: '1 rue de Paris',
});
expect(fixture.nativeElement.textContent).toContain('Cette adresse e-mail est déjà utilisée.');
expect(fixture.nativeElement.querySelector('input#email').value).toBe('alex@example.com');
expect(fixture.nativeElement.querySelector('input#profile-email').value).toBe(
'alex@example.com',
);
});
});
@@ -1,14 +1,12 @@
import { Component, inject, signal } from '@angular/core';
import { Component, DestroyRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
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 { FormRoot, FormField, form, required, email } from '@angular/forms/signals';
import { HttpErrorResponse } from '@angular/common/http';
import { LandlordProfileForm } from '../landlord-profile-form/landlord-profile-form';
import { httpErrorMessage } from '../../services/http-error-message';
import { LandlordProfile, LandlordProfileService } from '../../services/landlord-profile.service';
import { SessionService } from '../../services/session.service';
import { ThemeService } from '../../services/theme.service';
@@ -17,14 +15,10 @@ import { ThemeService } from '../../services/theme.service';
selector: 'app-settings-page',
imports: [
FontAwesomeModule,
FormsModule,
FormRoot,
FormField,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
MatRadioModule,
LandlordProfileForm,
],
templateUrl: './settings-page.html',
styleUrl: './settings-page.css',
@@ -36,6 +30,7 @@ export class SettingsPage {
readonly session = inject(SessionService);
readonly theme = inject(ThemeService);
private readonly landlordProfile = inject(LandlordProfileService);
private readonly destroyRef = inject(DestroyRef);
readonly profileStatus = signal<'loading' | 'ready' | 'error'>('loading');
readonly saving = signal(false);
@@ -48,15 +43,6 @@ export class SettingsPage {
address: '',
});
readonly profileForm = form(this.profileModel, (f) => {
required(f.firstName, { message: 'Veuillez renseigner tous les champs obligatoires.' });
required(f.lastName, { message: 'Veuillez renseigner tous les champs obligatoires.' });
required(f.email, { message: 'Veuillez renseigner tous les champs obligatoires.' });
required(f.phone, { message: 'Veuillez renseigner tous les champs obligatoires.' });
required(f.address, { message: 'Veuillez renseigner tous les champs obligatoires.' });
email(f.email, { message: 'Saisissez une adresse e-mail valide.' });
});
readonly profileError = signal('');
readonly saveError = signal('');
readonly saveSuccess = signal('');
@@ -72,57 +58,41 @@ export class SettingsPage {
loadProfile(): void {
this.profileStatus.set('loading');
this.profileError.set('');
this.landlordProfile.getProfile().subscribe({
next: (profile) => {
this.profileModel.set(profile);
this.profileStatus.set('ready');
},
error: () => {
this.profileStatus.set('error');
this.profileError.set('Impossible de charger votre profil bailleur. Réessayez.');
},
});
this.landlordProfile
.getProfile()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (profile) => {
this.profileModel.set(profile);
this.profileStatus.set('ready');
},
error: () => {
this.profileStatus.set('error');
this.profileError.set('Impossible de charger votre profil bailleur. Réessayez.');
},
});
}
save(): void {
save(profile: LandlordProfile): void {
if (this.saving()) return;
this.saveSuccess.set('');
this.profileForm().markAsTouched();
if (this.profileForm().invalid()) {
const errors = this.profileForm().errorSummary();
this.saveError.set(errors[0]?.message ?? 'Saisissez des informations valides.');
return;
}
this.saveError.set('');
this.saving.set(true);
this.landlordProfile.updateProfile(this.profileModel()).subscribe({
next: (savedProfile) => {
this.profileModel.set(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 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'
);
this.landlordProfile
.updateProfile(profile)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (savedProfile) => {
this.profileModel.set(savedProfile);
this.saveSuccess.set('Profil bailleur enregistré.');
this.saving.set(false);
},
error: (error: unknown) => {
this.saveError.set(
httpErrorMessage(error, 'Impossible d’enregistrer votre profil bailleur. Réessayez.'),
);
this.saving.set(false);
},
});
}
}