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

+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>
</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);
},
});
}
}
@@ -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);
}
}