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
+837 -216

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.
_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`):
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';
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/landlord-profile');
await page.route('**/api/user', async (route) => {
await route.fulfill({
contentType: 'application/json',
@@ -10,6 +11,23 @@ async function mockUser(page: Page, authenticated: boolean): Promise<void> {
: { 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> {
@@ -49,6 +67,25 @@ test('starts the OIDC login flow from the sign-in screen', async ({ page }) => {
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 }) => {
await mockUser(page, true);
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">
<app-brand-mark class="brand-mark" />
<mat-spinner diameter="34" />
<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>
</section>
} @else if (session.status() === 'error') {
+73 -1
View File
@@ -1,7 +1,11 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { App } from './app';
import { LandlordProfileService } from './services/landlord-profile.service';
import { SessionService, SessionStatus } from './services/session.service';
describe('App', () => {
@@ -14,6 +18,8 @@ describe('App', () => {
refresh: vi.fn(),
logout: vi.fn(),
};
const landlordProfile = { getProfile: vi.fn() };
const dialog = { open: vi.fn() };
beforeEach(() => {
status.set('checking');
@@ -21,8 +27,24 @@ describe('App', () => {
session.username.set('');
session.email.set('');
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({
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-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 { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatDialog } from '@angular/material/dialog';
import { RouterOutlet } from '@angular/router';
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 { WorkspaceNavigation } from './components/workspace-navigation/workspace-navigation';
import {
LandlordProfileResponse,
LandlordProfileService,
} from './services/landlord-profile.service';
import { SessionService } from './services/session.service';
import { ThemeService } from './services/theme.service';
@@ -18,7 +23,6 @@ import { ThemeService } from './services/theme.service';
RouterOutlet,
MatProgressSpinnerModule,
MatButtonModule,
MatDialogModule,
],
templateUrl: './app.html',
styleUrl: './app.css',
@@ -27,6 +31,32 @@ export class App implements OnInit {
readonly session = inject(SessionService);
readonly theme = inject(ThemeService);
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() {
this.session.refresh();
@@ -39,4 +69,12 @@ export class App implements OnInit {
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;
}
.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>
<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);
},
});
}
}
@@ -10,15 +10,20 @@ export interface LandlordProfile {
address: string;
}
export interface LandlordProfileResponse extends LandlordProfile {
complete: boolean;
version: number;
}
@Injectable({ providedIn: 'root' })
export class LandlordProfileService {
private readonly http = inject(HttpClient);
getProfile(): Observable<LandlordProfile> {
return this.http.get<LandlordProfile>('/api/landlord-profile');
getProfile(): Observable<LandlordProfileResponse> {
return this.http.get<LandlordProfileResponse>('/api/landlord-profile');
}
updateProfile(profile: LandlordProfile): Observable<LandlordProfile> {
return this.http.put<LandlordProfile>('/api/landlord-profile', profile);
updateProfile(profile: LandlordProfile): Observable<LandlordProfileResponse> {
return this.http.put<LandlordProfileResponse>('/api/landlord-profile', profile);
}
}