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

+3 -1
View File
@@ -260,7 +260,9 @@ _Avoid_: Tâche, rappel
Personne physique ou entité fiscale (ex. indivision) propriétaire des appartements, titulaire des prêts et émettrice des baux. Personne physique ou entité fiscale (ex. indivision) propriétaire des appartements, titulaire des prêts et émettrice des baux.
_Avoid_: Propriétaire, bailleur social _Avoid_: Propriétaire, bailleur social
La première version du profil bailleur ne couvre qu'une personne physique : prénom, nom, e-mail, téléphone et adresse postale. Chaque profil est lié au sujet OIDC (`sub`) de son compte ; l'e-mail est une coordonnée modifiable mais unique. Chaque bailleur possède un portefeuille isolé des autres comptes. Le profil est modifiable depuis les réglages. Ses coordonnées sont reprises dans leur intégralité dans les baux et les quittances de loyer. Aucun de ces PDF ne peut être généré tant que le profil n'est pas complet. **Profil bailleur** (Code: `LandlordProfile`):
Coordonnées personnelles du bailleur — prénom, nom, e-mail, téléphone et adresse postale — complètes seulement lorsque les cinq sont renseignées. Elles sont liées au sujet OIDC (`sub`) de son compte ; l'e-mail est modifiable mais unique, le portefeuille reste isolé et les coordonnées sont reprises dans les baux et quittances, qui ne peuvent être générés avant leur complétion.
_Avoid_: Compte OIDC, profil utilisateur
**Liasse fiscale** (Code: `TaxDeclarationPack`): **Liasse fiscale** (Code: `TaxDeclarationPack`):
Ensemble des documents comptables déclaratifs (formulaires Cerfa 2033-A à 2033-G en France) à transmettre annuellement à l'administration fiscale au régime réel. Ensemble des documents comptables déclaratifs (formulaires Cerfa 2033-A à 2033-G en France) à transmettre annuellement à l'administration fiscale au régime réel.
+38 -1
View File
@@ -1,7 +1,8 @@
import { expect, Page, test } from '@playwright/test'; import { expect, Page, test } from '@playwright/test';
async function mockUser(page: Page, authenticated: boolean): Promise<void> { async function mockUser(page: Page, authenticated: boolean, completeProfile = true): Promise<void> {
await page.unroute('**/api/user'); await page.unroute('**/api/user');
await page.unroute('**/api/landlord-profile');
await page.route('**/api/user', async (route) => { await page.route('**/api/user', async (route) => {
await route.fulfill({ await route.fulfill({
contentType: 'application/json', contentType: 'application/json',
@@ -10,6 +11,23 @@ async function mockUser(page: Page, authenticated: boolean): Promise<void> {
: { authenticated: false, username: '', email: '' }, : { authenticated: false, username: '', email: '' },
}); });
}); });
if (!authenticated) return;
const profile = {
firstName: 'Alice',
lastName: 'Martin',
email: 'alice@example.com',
phone: completeProfile ? '+33600000000' : '',
address: completeProfile ? '1 rue de Paris' : '',
complete: completeProfile,
version: 0,
};
await page.route('**/api/landlord-profile', async (route) => {
if (route.request().method() === 'PUT') {
Object.assign(profile, route.request().postDataJSON(), { complete: true, version: 1 });
}
await route.fulfill({ contentType: 'application/json', json: profile });
});
} }
async function mockEmptyApartmentList(page: Page): Promise<void> { async function mockEmptyApartmentList(page: Page): Promise<void> {
@@ -49,6 +67,25 @@ test('starts the OIDC login flow from the sign-in screen', async ({ page }) => {
await expect(page).toHaveURL(/\/oauth2\/authorization\/oidc$/); await expect(page).toHaveURL(/\/oauth2\/authorization\/oidc$/);
}); });
test('requires a newly connected landlord to complete their profile', async ({ page }) => {
await mockUser(page, true, false);
await page.goto('/');
const dialog = page.getByRole('dialog');
await expect(dialog.getByRole('heading', { name: 'Complétez votre profil' })).toBeVisible();
await page.keyboard.press('Escape');
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Déconnexion' })).toBeVisible();
await dialog.getByLabel('Téléphone').fill('+33612345678');
await dialog.getByLabel('Adresse postale').fill('1 rue des Lilas, Lyon');
await dialog.getByRole('button', { name: 'Enregistrer le profil' }).click();
await expect(dialog).toBeHidden();
await expect(page.getByTestId('primary-navigation')).toBeVisible();
});
test('persists a dark appearance preference from Settings', async ({ page }) => { test('persists a dark appearance preference from Settings', async ({ page }) => {
await mockUser(page, true); await mockUser(page, true);
await mockEmptyApartmentList(page); await mockEmptyApartmentList(page);
+10 -2
View File
@@ -1,9 +1,17 @@
@if (session.status() === 'checking' || session.status() === 'signing-out') { @if (
session.status() === 'checking' ||
session.status() === 'signing-out' ||
(session.isLoggedIn() && profileCheckStatus() === 'checking')
) {
<section class="session-state" role="status" aria-live="polite"> <section class="session-state" role="status" aria-live="polite">
<app-brand-mark class="brand-mark" /> <app-brand-mark class="brand-mark" />
<mat-spinner diameter="34" /> <mat-spinner diameter="34" />
<strong>{{ <strong>{{
session.status() === 'signing-out' ? 'Déconnexion…' : 'Vérification de votre session…' session.status() === 'signing-out'
? 'Déconnexion…'
: profileCheckStatus() === 'checking'
? 'Vérification de votre profil…'
: 'Vérification de votre session…'
}}</strong> }}</strong>
</section> </section>
} @else if (session.status() === 'error') { } @else if (session.status() === 'error') {
+73 -1
View File
@@ -1,7 +1,11 @@
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { App } from './app'; import { App } from './app';
import { LandlordProfileService } from './services/landlord-profile.service';
import { SessionService, SessionStatus } from './services/session.service'; import { SessionService, SessionStatus } from './services/session.service';
describe('App', () => { describe('App', () => {
@@ -14,6 +18,8 @@ describe('App', () => {
refresh: vi.fn(), refresh: vi.fn(),
logout: vi.fn(), logout: vi.fn(),
}; };
const landlordProfile = { getProfile: vi.fn() };
const dialog = { open: vi.fn() };
beforeEach(() => { beforeEach(() => {
status.set('checking'); status.set('checking');
@@ -21,8 +27,24 @@ describe('App', () => {
session.username.set(''); session.username.set('');
session.email.set(''); session.email.set('');
vi.clearAllMocks(); vi.clearAllMocks();
landlordProfile.getProfile.mockReturnValue(
of({
firstName: 'Camille',
lastName: 'Martin',
email: 'camille@example.com',
phone: '+33600000000',
address: '1 rue de Paris',
complete: true,
version: 0,
}),
);
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [provideRouter([]), { provide: SessionService, useValue: session }], providers: [
provideRouter([]),
{ provide: SessionService, useValue: session },
{ provide: LandlordProfileService, useValue: landlordProfile },
{ provide: MatDialog, useValue: dialog },
],
}); });
}); });
@@ -64,4 +86,54 @@ describe('App', () => {
expect(fixture.nativeElement.querySelector('app-workspace-navigation')).not.toBeNull(); expect(fixture.nativeElement.querySelector('app-workspace-navigation')).not.toBeNull();
expect(fixture.nativeElement.querySelector('app-sign-in-page')).toBeNull(); expect(fixture.nativeElement.querySelector('app-sign-in-page')).toBeNull();
}); });
it('opens an unclosable profile-completion dialog for an incomplete landlord profile', () => {
status.set('authenticated');
session.isLoggedIn.set(true);
landlordProfile.getProfile.mockReturnValueOnce(
of({
firstName: 'Camille',
lastName: 'Martin',
email: 'camille@example.com',
phone: '',
address: '',
complete: false,
version: 0,
}),
);
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
expect(dialog.open).toHaveBeenCalledOnce();
expect(dialog.open).toHaveBeenCalledWith(expect.any(Function), {
disableClose: true,
closeOnNavigation: false,
data: expect.objectContaining({
profile: expect.objectContaining({ phone: '', address: '' }),
}),
});
});
it('does not open the profile-completion dialog for a complete landlord profile', () => {
status.set('authenticated');
session.isLoggedIn.set(true);
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
expect(dialog.open).not.toHaveBeenCalled();
});
it('keeps profile completion available when the initial profile request fails', () => {
status.set('authenticated');
session.isLoggedIn.set(true);
landlordProfile.getProfile.mockReturnValueOnce(throwError(() => new Error('offline')));
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
expect(dialog.open).toHaveBeenCalledWith(expect.any(Function), {
disableClose: true,
closeOnNavigation: false,
data: { profile: null },
});
});
}); });
+41 -3
View File
@@ -1,11 +1,16 @@
import { Component, computed, inject, OnInit } from '@angular/core'; import { Component, computed, effect, inject, OnInit, signal, untracked } from '@angular/core';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { BrandMark } from './components/brand-mark'; import { BrandMark } from './components/brand-mark';
import { LandlordProfileCompletionDialog } from './components/landlord-profile-completion-dialog/landlord-profile-completion-dialog';
import { SignInPage } from './components/sign-in-page/sign-in-page'; import { SignInPage } from './components/sign-in-page/sign-in-page';
import { WorkspaceNavigation } from './components/workspace-navigation/workspace-navigation'; import { WorkspaceNavigation } from './components/workspace-navigation/workspace-navigation';
import {
LandlordProfileResponse,
LandlordProfileService,
} from './services/landlord-profile.service';
import { SessionService } from './services/session.service'; import { SessionService } from './services/session.service';
import { ThemeService } from './services/theme.service'; import { ThemeService } from './services/theme.service';
@@ -18,7 +23,6 @@ import { ThemeService } from './services/theme.service';
RouterOutlet, RouterOutlet,
MatProgressSpinnerModule, MatProgressSpinnerModule,
MatButtonModule, MatButtonModule,
MatDialogModule,
], ],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.css', styleUrl: './app.css',
@@ -27,6 +31,32 @@ export class App implements OnInit {
readonly session = inject(SessionService); readonly session = inject(SessionService);
readonly theme = inject(ThemeService); readonly theme = inject(ThemeService);
readonly userInitial = computed(() => this.session.username().charAt(0).toUpperCase()); readonly userInitial = computed(() => this.session.username().charAt(0).toUpperCase());
private readonly dialog = inject(MatDialog);
private readonly landlordProfile = inject(LandlordProfileService);
readonly profileCheckStatus = signal<'idle' | 'checking' | 'checked'>('idle');
constructor() {
effect((onCleanup) => {
if (!this.session.isLoggedIn()) {
this.profileCheckStatus.set('idle');
return;
}
if (untracked(this.profileCheckStatus) !== 'idle') return;
this.profileCheckStatus.set('checking');
const profileRequest = this.landlordProfile.getProfile().subscribe({
next: (profile) => {
this.profileCheckStatus.set('checked');
if (!profile.complete) this.openProfileCompletion(profile);
},
error: () => {
this.profileCheckStatus.set('checked');
this.openProfileCompletion(null);
},
});
onCleanup(() => profileRequest.unsubscribe());
});
}
ngOnInit() { ngOnInit() {
this.session.refresh(); this.session.refresh();
@@ -39,4 +69,12 @@ export class App implements OnInit {
logout() { logout() {
this.session.logout(); this.session.logout();
} }
private openProfileCompletion(profile: LandlordProfileResponse | null): void {
this.dialog.open(LandlordProfileCompletionDialog, {
disableClose: true,
closeOnNavigation: false,
data: { profile },
});
}
} }
@@ -0,0 +1,30 @@
.profile-dialog-content {
display: grid;
width: min(36rem, calc(100vw - 3rem));
max-width: 100%;
gap: var(--space-4, 1rem);
max-height: 75vh;
overscroll-behavior-y: contain;
container-type: inline-size;
}
.profile-dialog-content > p,
.profile-error p {
margin: 0;
color: var(--text-muted, #687670);
line-height: 1.5;
}
.profile-loading,
.profile-error {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3, 0.75rem);
min-height: 3rem;
}
.profile-error {
align-items: flex-start;
flex-direction: column;
}
@@ -0,0 +1,25 @@
<h2 mat-dialog-title>Complétez votre profil</h2>
<mat-dialog-content class="profile-dialog-content">
<p>Vos coordonnées sont nécessaires pour créer vos baux et vos quittances.</p>
@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-error">
<p role="alert">{{ error() }}</p>
<button mat-stroked-button type="button" (click)="retry()">Réessayer</button>
</div>
} @else {
<app-landlord-profile-form
[profile]="profile()"
[saving]="saving()"
[saveError]="error()"
(saveRequested)="save($event)"
/>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="logout()" [disabled]="saving()">Déconnexion</button>
</mat-dialog-actions>
@@ -0,0 +1,142 @@
import { HttpErrorResponse } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { LandlordProfileService } from '../../services/landlord-profile.service';
import { SessionService } from '../../services/session.service';
import {
LandlordProfileCompletionDialog,
LandlordProfileCompletionDialogData,
} from './landlord-profile-completion-dialog';
describe('LandlordProfileCompletionDialog', () => {
const profile = { getProfile: vi.fn(), updateProfile: vi.fn() };
const dialogRef = { close: vi.fn() };
const session = { logout: vi.fn() };
const data: LandlordProfileCompletionDialogData = {
profile: {
firstName: 'Camille',
lastName: 'Martin',
email: 'camille@example.com',
phone: '',
address: '',
complete: false,
version: 0,
},
};
beforeEach(() => {
vi.clearAllMocks();
data.profile = {
firstName: 'Camille',
lastName: 'Martin',
email: 'camille@example.com',
phone: '',
address: '',
complete: false,
version: 0,
};
TestBed.configureTestingModule({
providers: [
{ provide: MAT_DIALOG_DATA, useValue: data },
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: LandlordProfileService, useValue: profile },
{ provide: SessionService, useValue: session },
],
});
});
it('saves the completed profile before closing the dialog', () => {
profile.updateProfile.mockReturnValue(
of({ ...data.profile, phone: '+33600000000', address: '1 rue A', complete: true }),
);
const fixture = TestBed.createComponent(LandlordProfileCompletionDialog);
const component = fixture.componentInstance;
fixture.detectChanges();
component.profile.update((current) => ({
...current,
phone: '+33600000000',
address: '1 rue A',
}));
component.save(component.profile());
expect(profile.updateProfile).toHaveBeenCalledWith({
firstName: 'Camille',
lastName: 'Martin',
email: 'camille@example.com',
phone: '+33600000000',
address: '1 rue A',
});
expect(dialogRef.close).toHaveBeenCalledOnce();
});
it('keeps the dialog open and explains a rejected save', () => {
profile.updateProfile.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
error: { message: 'Cette adresse e-mail est déjà utilisée.' },
status: 409,
}),
),
);
const fixture = TestBed.createComponent(LandlordProfileCompletionDialog);
const component = fixture.componentInstance;
fixture.detectChanges();
component.profile.update((current) => ({
...current,
phone: '+33600000000',
address: '1 rue A',
}));
component.save(component.profile());
fixture.detectChanges();
expect(dialogRef.close).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain('Cette adresse e-mail est déjà utilisée.');
expect(fixture.nativeElement.textContent).toContain('Réessayer');
});
it('retries an initial profile load without releasing the dialog', () => {
data.profile = null;
profile.getProfile.mockReturnValue(
of({
firstName: 'Camille',
lastName: 'Martin',
email: 'camille@example.com',
phone: '',
address: '',
complete: false,
version: 0,
}),
);
const fixture = TestBed.createComponent(LandlordProfileCompletionDialog);
const component = fixture.componentInstance;
fixture.detectChanges();
component.retry();
fixture.detectChanges();
expect(profile.getProfile).toHaveBeenCalledOnce();
expect(dialogRef.close).not.toHaveBeenCalled();
expect(fixture.nativeElement.querySelector('input#profile-phone').value).toBe('');
});
it('keeps the load error visible when a retry fails', () => {
data.profile = null;
profile.getProfile.mockReturnValue(throwError(() => new Error('offline')));
const fixture = TestBed.createComponent(LandlordProfileCompletionDialog);
const component = fixture.componentInstance;
fixture.detectChanges();
component.retry();
fixture.detectChanges();
expect(dialogRef.close).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain(
'Impossible de charger votre profil bailleur. Réessayez.',
);
});
});
@@ -0,0 +1,94 @@
import { Component, DestroyRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { LandlordProfileForm } from '../landlord-profile-form/landlord-profile-form';
import {
LandlordProfile,
LandlordProfileResponse,
LandlordProfileService,
} from '../../services/landlord-profile.service';
import { httpErrorMessage } from '../../services/http-error-message';
import { SessionService } from '../../services/session.service';
export interface LandlordProfileCompletionDialogData {
profile: LandlordProfileResponse | null;
}
@Component({
selector: 'app-landlord-profile-completion-dialog',
imports: [MatButtonModule, MatDialogModule, MatProgressSpinnerModule, LandlordProfileForm],
templateUrl: './landlord-profile-completion-dialog.html',
styleUrl: './landlord-profile-completion-dialog.css',
})
export class LandlordProfileCompletionDialog {
readonly data = inject<LandlordProfileCompletionDialogData>(MAT_DIALOG_DATA);
private readonly dialogRef = inject(MatDialogRef<LandlordProfileCompletionDialog>);
private readonly landlordProfile = inject(LandlordProfileService);
private readonly session = inject(SessionService);
private readonly destroyRef = inject(DestroyRef);
readonly profileStatus = signal<'ready' | 'loading' | 'error'>(
this.data.profile ? 'ready' : 'error',
);
readonly saving = signal(false);
readonly error = signal(
this.data.profile ? '' : 'Impossible de charger votre profil bailleur. Réessayez.',
);
readonly profile = signal<LandlordProfile>(this.profileFields(this.data.profile));
retry(): void {
this.profileStatus.set('loading');
this.error.set('');
this.landlordProfile
.getProfile()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (profile) => {
if (profile.complete) {
this.dialogRef.close();
return;
}
this.profile.set(this.profileFields(profile));
this.profileStatus.set('ready');
},
error: () => {
this.profileStatus.set('error');
this.error.set('Impossible de charger votre profil bailleur. Réessayez.');
},
});
}
save(profile: LandlordProfile): void {
if (this.saving()) return;
this.error.set('');
this.saving.set(true);
this.landlordProfile
.updateProfile(profile)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => this.dialogRef.close(),
error: (error: unknown) => {
this.error.set(
httpErrorMessage(error, 'Impossible d’enregistrer votre profil bailleur. Réessayez.'),
);
this.saving.set(false);
},
});
}
logout(): void {
this.session.logout();
}
private profileFields(profile: LandlordProfile | null): LandlordProfile {
return {
firstName: profile?.firstName ?? '',
lastName: profile?.lastName ?? '',
email: profile?.email ?? '',
phone: profile?.phone ?? '',
address: profile?.address ?? '',
};
}
}
@@ -0,0 +1,43 @@
:host {
display: block;
container-type: inline-size;
}
.profile-form {
display: grid;
gap: var(--space-4, 1rem);
}
.profile-error {
margin: 0;
color: var(--danger, #b42318);
}
.profile-fields {
display: grid;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
gap: var(--space-1, 0.25rem) var(--space-3, 0.75rem);
}
.profile-fields mat-form-field {
width: 100%;
}
.profile-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
}
@container (min-width: 32rem) {
.profile-fields {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.profile-fields .full-field {
grid-column: 1 / -1;
}
}
@@ -0,0 +1,65 @@
<form
class="profile-form"
[formRoot]="profileForm"
(ngSubmit)="submit()"
[attr.aria-busy]="saving()"
>
@if (error()) {
<p class="profile-error" role="alert">{{ error() }}</p>
}
<fieldset class="profile-fields" [disabled]="saving()" [attr.inert]="saving() ? '' : null">
<mat-form-field appearance="outline"
><mat-label>Prénom</mat-label
><input
matInput
id="profile-firstName"
[formField]="profileForm.firstName"
autocomplete="given-name"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Nom</mat-label
><input
matInput
id="profile-lastName"
[formField]="profileForm.lastName"
autocomplete="family-name"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>E-mail</mat-label
><input
matInput
id="profile-email"
type="email"
[formField]="profileForm.email"
autocomplete="email"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Téléphone</mat-label
><input
matInput
id="profile-phone"
type="tel"
[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="profile-address"
[formField]="profileForm.address"
autocomplete="street-address"
/></mat-form-field>
</fieldset>
<div class="profile-actions">
<button mat-flat-button type="submit" class="primary-action">
@if (saving()) {
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
} @else if (error()) {
Réessayer
} @else {
Enregistrer le profil
}
</button>
</div>
</form>
@@ -0,0 +1,53 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { LandlordProfileForm } from './landlord-profile-form';
describe('LandlordProfileForm', () => {
it('explains incomplete profile data without requesting a save', () => {
const fixture = TestBed.createComponent(LandlordProfileForm);
const component = fixture.componentInstance;
const saveRequested = vi.fn();
component.saveRequested.subscribe(saveRequested);
fixture.componentRef.setInput('profile', {
firstName: 'Camille',
lastName: 'Martin',
email: 'camille@example.com',
phone: '',
address: '',
});
fixture.detectChanges();
component.submit();
fixture.detectChanges();
expect(saveRequested).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain(
'Veuillez renseigner tous les champs obligatoires.',
);
});
it('emits a complete profile for saving', () => {
const fixture = TestBed.createComponent(LandlordProfileForm);
const component = fixture.componentInstance;
const saveRequested = vi.fn();
component.saveRequested.subscribe(saveRequested);
fixture.componentRef.setInput('profile', {
firstName: 'Camille',
lastName: 'Martin',
email: 'camille@example.com',
phone: '+33600000000',
address: '1 rue de Paris',
});
fixture.detectChanges();
component.submit();
expect(saveRequested).toHaveBeenCalledWith({
firstName: 'Camille',
lastName: 'Martin',
email: 'camille@example.com',
phone: '+33600000000',
address: '1 rue de Paris',
});
});
});
@@ -0,0 +1,68 @@
import { Component, computed, effect, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { email, form, FormField, FormRoot, required } from '@angular/forms/signals';
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 { LandlordProfile } from '../../services/landlord-profile.service';
const EMPTY_PROFILE: LandlordProfile = {
firstName: '',
lastName: '',
email: '',
phone: '',
address: '',
};
@Component({
selector: 'app-landlord-profile-form',
imports: [
FormsModule,
FormRoot,
FormField,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
],
templateUrl: './landlord-profile-form.html',
styleUrl: './landlord-profile-form.css',
})
export class LandlordProfileForm {
readonly profile = input<LandlordProfile>(EMPTY_PROFILE);
readonly saving = input(false);
readonly saveError = input('');
readonly saveRequested = output<LandlordProfile>();
readonly profileModel = signal<LandlordProfile>({ ...this.profile() });
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.' });
});
private readonly validationError = signal('');
readonly error = computed(() => this.validationError() || this.saveError());
constructor() {
effect(() => this.profileModel.set({ ...this.profile() }));
}
submit(): void {
if (this.saving()) return;
this.profileForm().markAsTouched();
if (this.profileForm().invalid()) {
this.validationError.set(
this.profileForm().errorSummary()[0]?.message ?? 'Saisissez des informations valides.',
);
return;
}
this.validationError.set('');
this.saveRequested.emit(this.profileModel());
}
}
@@ -90,7 +90,6 @@
flex-direction: column; flex-direction: column;
} }
.profile-message .alert-error, .profile-message .alert-error,
.profile-form .alert-error,
.profile-success { .profile-success {
margin: 0 0 16px; margin: 0 0 16px;
} }
@@ -98,19 +97,6 @@
color: var(--accent-strong, #0a5f59); color: var(--accent-strong, #0a5f59);
font-weight: 600; 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 { .session-action {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -152,10 +138,4 @@
align-items: flex-start; align-items: flex-start;
justify-content: center; 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"> <section class="page-section settings-page">
<header class="page-heading"> <header class="page-heading">
<div> <div>
<p class="eyebrow">Préférences</p> <p class="eyebrow">Préférences</p>
<h1>Réglages</h1> <h1>Réglages</h1>
<p>Adaptez LMNP Organizer à votre façon de travailler.</p> <p>Adaptez LMNP Organizer à votre façon de travailler.</p>
</div> </div>
</header> </header>
<article class="settings-card" aria-labelledby="appearance-heading"> <article class="settings-card" aria-labelledby="appearance-heading">
<div class="settings-copy"> <div class="settings-copy">
<span class="settings-icon" aria-hidden="true"><fa-icon [icon]="faMoon" /></span> <span class="settings-icon" aria-hidden="true"><fa-icon [icon]="faMoon" /></span>
<div> <div>
<p class="eyebrow">Interface</p> <p class="eyebrow">Interface</p>
<h2 id="appearance-heading">Apparence</h2> <h2 id="appearance-heading">Apparence</h2>
<p>Le réglage est mémorisé uniquement sur cet appareil.</p> <p>Le réglage est mémorisé uniquement sur cet appareil.</p>
</div> </div>
</div> </div>
<mat-radio-group class="theme-options" aria-label="Thème de l'application" [value]="theme.preference()" <mat-radio-group
(change)="theme.setPreference($event.value)"> class="theme-options"
<mat-radio-button value="light"><span class="theme-option-copy"><strong>Clair</strong><small>Une interface aria-label="Thème de l'application"
lumineuse et douce.</small></span></mat-radio-button> [value]="theme.preference()"
<mat-radio-button value="dark"><span class="theme-option-copy"><strong>Sombre</strong><small>Un contraste (change)="theme.setPreference($event.value)"
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 <mat-radio-button value="light"
automatiquement votre appareil.</small></span></mat-radio-button> ><span class="theme-option-copy"
</mat-radio-group> ><strong>Clair</strong><small>Une interface lumineuse et douce.</small></span
</article> ></mat-radio-button
<article class="settings-card landlord-profile" aria-labelledby="profile-heading"> >
<div class="settings-copy"> <mat-radio-button value="dark"
<span class="settings-icon" aria-hidden="true"><fa-icon [icon]="faAddressCard" /></span> ><span class="theme-option-copy"
<div> ><strong>Sombre</strong><small>Un contraste réduit en faible lumière.</small></span
<p class="eyebrow">Bailleur</p> ></mat-radio-button
<h2 id="profile-heading">Profil personnel</h2> >
<p>Ces coordonnées sont utilisées dans vos baux et vos quittances.</p> <mat-radio-button value="system"
</div> ><span class="theme-option-copy"
</div> ><strong>Système</strong><small>Suit automatiquement votre appareil.</small></span
<div class="profile-content"> ></mat-radio-button
@if (profileStatus() === 'loading') { >
<div class="profile-loading" role="status" aria-live="polite"> </mat-radio-group>
<mat-spinner diameter="24" aria-hidden="true" /> Chargement de votre profil… </article>
</div> <article class="settings-card landlord-profile" aria-labelledby="profile-heading">
} @else if (profileStatus() === 'error') { <div class="settings-copy">
<div class="profile-message"> <span class="settings-icon" aria-hidden="true"><fa-icon [icon]="faAddressCard" /></span>
<p class="alert-error" role="alert">{{ profileError() }}</p> <div>
<button mat-stroked-button type="button" (click)="loadProfile()">Réessayer</button> <p class="eyebrow">Bailleur</p>
</div> <h2 id="profile-heading">Profil personnel</h2>
} @else { <p>Ces coordonnées sont utilisées dans vos baux et vos quittances.</p>
<form class="profile-form" [formRoot]="profileForm" (ngSubmit)="save()" [attr.aria-busy]="saving()"> </div>
@if (saveError()) { </div>
<p class="alert-error" role="alert">{{ saveError() }}</p> <div class="profile-content">
} @if (profileStatus() === 'loading') {
@if (saveSuccess()) { <div class="profile-loading" role="status" aria-live="polite">
<p class="profile-success" role="status">{{ saveSuccess() }}</p> <mat-spinner diameter="24" aria-hidden="true" /> Chargement de votre profil…
} </div>
<fieldset class="busy-fieldset" [disabled]="saving()" [attr.inert]="saving() ? '' : null"> } @else if (profileStatus() === 'error') {
<div class="profile-fields"> <div class="profile-message">
<mat-form-field appearance="outline"><mat-label>Prénom</mat-label><input matInput id="firstName" <p class="alert-error" role="alert">{{ profileError() }}</p>
[formField]="profileForm.firstName" autocomplete="given-name" /></mat-form-field> <button mat-stroked-button type="button" (click)="loadProfile()">Réessayer</button>
<mat-form-field appearance="outline"><mat-label>Nom</mat-label><input matInput id="lastName" </div>
[formField]="profileForm.lastName" autocomplete="family-name" /></mat-form-field> } @else {
<mat-form-field appearance="outline"><mat-label>E-mail</mat-label><input matInput type="email" @if (saveSuccess()) {
id="email" [formField]="profileForm.email" autocomplete="email" /></mat-form-field> <p class="profile-success" role="status">{{ saveSuccess() }}</p>
<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> <app-landlord-profile-form
<mat-form-field appearance="outline" class="full-field"><mat-label>Adresse [profile]="profileModel()"
postale</mat-label><input matInput id="address" [formField]="profileForm.address" [saving]="saving()"
autocomplete="street-address" /></mat-form-field> [saveError]="saveError()"
</div> (saveRequested)="save($event)"
<div class="profile-actions"> />
<button mat-flat-button type="submit" class="primary-action"> }
@if (saving()) { </div>
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement… </article>
} @else { <article
Enregistrer le profil class="settings-card session-settings"
} data-testid="session-settings"
</button> aria-labelledby="session-heading"
</div> >
</fieldset> <div class="settings-copy">
</form> <span class="settings-icon" aria-hidden="true"><fa-icon [icon]="faUser" /></span>
} <div>
</div> <p class="eyebrow">Compte</p>
</article> <h2 id="session-heading">Session</h2>
<article class="settings-card session-settings" data-testid="session-settings" aria-labelledby="session-heading"> <p>Gérez la session ouverte sur cet appareil.</p>
<div class="settings-copy"> </div>
<span class="settings-icon" aria-hidden="true"><fa-icon [icon]="faUser" /></span> </div>
<div> <div class="session-action">
<p class="eyebrow">Compte</p> <div>
<h2 id="session-heading">Session</h2> <strong>{{ session.username() }}</strong
<p>Gérez la session ouverte sur cet appareil.</p> ><span>{{ session.email() }}</span>
</div> </div>
</div> <button mat-stroked-button type="button" (click)="logout()">Déconnexion</button>
<div class="session-action"> </div>
<div> </article>
<strong>{{ session.username() }}</strong><span>{{ session.email() }}</span> </section>
</div>
<button mat-stroked-button type="button" (click)="logout()">Déconnexion</button>
</div>
</article>
</section>
@@ -48,24 +48,13 @@ describe('SettingsPage', () => {
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); await fixture.whenStable();
expect(fixture.nativeElement.querySelector('input#firstName').value).toBe('Camille'); expect(fixture.nativeElement.querySelector('input#profile-firstName').value).toBe('Camille');
expect(fixture.nativeElement.querySelector('input#email').value).toBe('camille@example.com'); expect(fixture.nativeElement.querySelector('input#profile-email').value).toBe(
expect(fixture.nativeElement.querySelector('input#address').value).toBe('1 rue de Paris'); 'camille@example.com',
}); );
expect(fixture.nativeElement.querySelector('input#profile-address').value).toBe(
it('shows a useful error instead of saving an incomplete profile', () => { '1 rue de Paris',
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(profile.updateProfile).not.toHaveBeenCalled();
}); });
it('keeps the entered values and explains a rejected profile save', async () => { it('keeps the entered values and explains a rejected profile save', async () => {
@@ -80,10 +69,13 @@ describe('SettingsPage', () => {
); );
const fixture = TestBed.createComponent(SettingsPage); const fixture = TestBed.createComponent(SettingsPage);
fixture.detectChanges(); fixture.detectChanges();
const component = fixture.componentInstance; await fixture.whenStable();
component.profileModel.update((p) => ({ ...p, email: 'alex@example.com' })); 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(); fixture.detectChanges();
await fixture.whenStable(); await fixture.whenStable();
@@ -95,6 +87,8 @@ describe('SettingsPage', () => {
address: '1 rue de Paris', address: '1 rue de Paris',
}); });
expect(fixture.nativeElement.textContent).toContain('Cette adresse e-mail est déjà utilisée.'); 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 { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { faAddressCard, 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 { 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 { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatRadioModule } from '@angular/material/radio'; import { MatRadioModule } from '@angular/material/radio';
import { FormsModule } from '@angular/forms'; import { LandlordProfileForm } from '../landlord-profile-form/landlord-profile-form';
import { FormRoot, FormField, form, required, email } from '@angular/forms/signals'; import { httpErrorMessage } from '../../services/http-error-message';
import { HttpErrorResponse } from '@angular/common/http';
import { LandlordProfile, LandlordProfileService } from '../../services/landlord-profile.service'; import { LandlordProfile, LandlordProfileService } from '../../services/landlord-profile.service';
import { SessionService } from '../../services/session.service'; import { SessionService } from '../../services/session.service';
import { ThemeService } from '../../services/theme.service'; import { ThemeService } from '../../services/theme.service';
@@ -17,14 +15,10 @@ import { ThemeService } from '../../services/theme.service';
selector: 'app-settings-page', selector: 'app-settings-page',
imports: [ imports: [
FontAwesomeModule, FontAwesomeModule,
FormsModule,
FormRoot,
FormField,
MatButtonModule, MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule, MatProgressSpinnerModule,
MatRadioModule, MatRadioModule,
LandlordProfileForm,
], ],
templateUrl: './settings-page.html', templateUrl: './settings-page.html',
styleUrl: './settings-page.css', styleUrl: './settings-page.css',
@@ -36,6 +30,7 @@ export class SettingsPage {
readonly session = inject(SessionService); readonly session = inject(SessionService);
readonly theme = inject(ThemeService); readonly theme = inject(ThemeService);
private readonly landlordProfile = inject(LandlordProfileService); private readonly landlordProfile = inject(LandlordProfileService);
private readonly destroyRef = inject(DestroyRef);
readonly profileStatus = signal<'loading' | 'ready' | 'error'>('loading'); readonly profileStatus = signal<'loading' | 'ready' | 'error'>('loading');
readonly saving = signal(false); readonly saving = signal(false);
@@ -48,15 +43,6 @@ export class SettingsPage {
address: '', 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 profileError = signal('');
readonly saveError = signal(''); readonly saveError = signal('');
readonly saveSuccess = signal(''); readonly saveSuccess = signal('');
@@ -72,57 +58,41 @@ export class SettingsPage {
loadProfile(): void { loadProfile(): void {
this.profileStatus.set('loading'); this.profileStatus.set('loading');
this.profileError.set(''); this.profileError.set('');
this.landlordProfile.getProfile().subscribe({ this.landlordProfile
next: (profile) => { .getProfile()
this.profileModel.set(profile); .pipe(takeUntilDestroyed(this.destroyRef))
this.profileStatus.set('ready'); .subscribe({
}, next: (profile) => {
error: () => { this.profileModel.set(profile);
this.profileStatus.set('error'); this.profileStatus.set('ready');
this.profileError.set('Impossible de charger votre profil bailleur. Réessayez.'); },
}, 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; if (this.saving()) return;
this.saveSuccess.set(''); 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.saveError.set('');
this.saving.set(true); this.saving.set(true);
this.landlordProfile.updateProfile(this.profileModel()).subscribe({ this.landlordProfile
next: (savedProfile) => { .updateProfile(profile)
this.profileModel.set(savedProfile); .pipe(takeUntilDestroyed(this.destroyRef))
this.saveSuccess.set('Profil bailleur enregistré.'); .subscribe({
this.saving.set(false); next: (savedProfile) => {
}, this.profileModel.set(savedProfile);
error: (error: unknown) => { this.saveSuccess.set('Profil bailleur enregistré.');
this.saveError.set(this.saveErrorMessage(error)); this.saving.set(false);
this.saving.set(false); },
}, error: (error: unknown) => {
}); this.saveError.set(
} httpErrorMessage(error, 'Impossible d’enregistrer votre profil bailleur. Réessayez.'),
);
private saveErrorMessage(error: unknown): string { this.saving.set(false);
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'
);
} }
} }
@@ -10,15 +10,20 @@ export interface LandlordProfile {
address: string; address: string;
} }
export interface LandlordProfileResponse extends LandlordProfile {
complete: boolean;
version: number;
}
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class LandlordProfileService { export class LandlordProfileService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
getProfile(): Observable<LandlordProfile> { getProfile(): Observable<LandlordProfileResponse> {
return this.http.get<LandlordProfile>('/api/landlord-profile'); return this.http.get<LandlordProfileResponse>('/api/landlord-profile');
} }
updateProfile(profile: LandlordProfile): Observable<LandlordProfile> { updateProfile(profile: LandlordProfile): Observable<LandlordProfileResponse> {
return this.http.put<LandlordProfile>('/api/landlord-profile', profile); return this.http.put<LandlordProfileResponse>('/api/landlord-profile', profile);
} }
} }