import type { ConsentDto, UserDto } from '@iti/shared' import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, viewChild } from '@angular/core' import { rxResource } from '@angular/core/rxjs-interop' import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' import { Title } from '@angular/platform-browser' import { Router, RouterLink } from '@angular/router' import { firstValueFrom } from 'rxjs' import { ApiService, toApiError } from '../../core/api.service' import { APP_CONFIG } from '../../core/app-config' import { AuthStore, type SessionResponse } from '../../core/auth.store' import { formatDateTime } from '../../core/format' import { ROLE_LABELS } from '../../core/roles' import { ConfirmDialog } from '../../shared/confirm-dialog' import { applyServerErrors } from '../../shared/form-errors' import { FormField } from '../../shared/form-field' import { Loading, LoadError } from '../../shared/state' import { mustMatch, PASSWORD_MIN } from '../auth/auth-helpers' interface ConsentsResponse { marketing: boolean items: ConsentDto[] } const CONSENT_SOURCES: Record = { register: 'registrazione', contact: 'modulo contatti', showroom: 'prenotazione showroom', configurator: 'configuratore', checkout: 'checkout', account: 'area riservata', } /** Corpo di `PATCH /me`: campi aziendali solo per le aziende, stringhe vuote → null. */ export function profilePatch(role: UserDto['role'], value: { firstName: string; lastName: string; companyName: string; vatNumber: string }) { return { firstName: value.firstName.trim(), lastName: value.lastName.trim(), ...(role === 'company' ? { companyName: value.companyName.trim(), vatNumber: value.vatNumber.trim() } : {}), } } /** * Il mio account: dati anagrafici (campi aziendali per le aziende), cambio password, consensi (marketing * revocabile, registro), richiesta di cancellazione dell'account. */ @Component({ selector: 'app-profile', imports: [ReactiveFormsModule, RouterLink, FormField, ConfirmDialog, Loading, LoadError], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './profile.html', styles: ` .single { grid-template-columns: 1fr; max-width: 32rem; } .form-grid { margin-bottom: var(--space-4); } .email { margin-bottom: var(--space-4); } .consents { width: 100%; border-collapse: collapse; font-size: var(--font-size-sm); th, td { padding: var(--space-2); border-bottom: 1px solid var(--color-line); text-align: left; } } .table-wrap { overflow-x: auto; } details summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; } .danger-zone { border-color: var(--color-danger); } `, }) export default class Profile { private readonly api = inject(ApiService) private readonly router = inject(Router) protected readonly auth = inject(AuthStore) protected readonly siteUrl = inject(APP_CONFIG).siteUrl protected readonly role = computed(() => this.auth.role() ?? 'customer') protected readonly roleLabel = computed(() => ROLE_LABELS[this.role()]) // --- Dati personali --- protected readonly details = new FormGroup({ firstName: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(100)] }), lastName: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(100)] }), companyName: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(200)] }), vatNumber: new FormControl('', { nonNullable: true, validators: [Validators.minLength(5), Validators.maxLength(20)] }), }) protected readonly detailsState = this.formState() // --- Password --- protected readonly password = new FormGroup({ currentPassword: new FormControl('', { nonNullable: true, validators: [Validators.required] }), newPassword: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(PASSWORD_MIN), Validators.maxLength(128)], }), confirm: new FormControl('', { nonNullable: true }), }) protected readonly passwordState = this.formState() // --- Consensi --- protected readonly consents = rxResource({ stream: () => this.api.get('/me/consents') }) protected readonly consentBusy = signal(false) protected readonly consentError = signal(null) // --- Cancellazione --- protected readonly deletion = new FormGroup({ password: new FormControl('', { nonNullable: true, validators: [Validators.required] }), reason: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(2000)] }), }) protected readonly deletionState = this.formState() private readonly deleteDialog = viewChild('deleteDialog') protected readonly dateTime = formatDateTime protected readonly sources = CONSENT_SOURCES constructor() { inject(Title).setTitle('Il mio account | Area riservata ITI TECHNOLOGY') mustMatch(this.password.controls.newPassword, this.password.controls.confirm) // Form precompilato con l'utente corrente (anche dopo un salvataggio) effect(() => { const user = this.auth.user() if (!user) return this.details.reset({ firstName: user.firstName, lastName: user.lastName, companyName: user.companyName ?? '', vatNumber: user.vatNumber ?? '', }) const { companyName, vatNumber } = this.details.controls if (user.role === 'company') { companyName.setValidators([Validators.required, Validators.maxLength(200)]) vatNumber.setValidators([Validators.required, Validators.minLength(5), Validators.maxLength(20)]) } else { companyName.clearValidators() vatNumber.clearValidators() } companyName.updateValueAndValidity() vatNumber.updateValueAndValidity() }) } protected async saveDetails(): Promise { const state = this.detailsState state.start() if (this.details.invalid) return state.stop() try { const user = await firstValueFrom(this.api.patch('/me', profilePatch(this.role(), this.details.getRawValue()))) this.auth.setUser(user) state.done('Dati aggiornati.') } catch (err) { const error = toApiError(err) state.fail(error.code === 'VALIDATION_ERROR' && applyServerErrors(this.details, error) ? 'Controlla i campi evidenziati.' : error.message) } } protected async changePassword(): Promise { const state = this.passwordState state.start() if (this.password.invalid) return state.stop() try { const { currentPassword, newPassword } = this.password.getRawValue() const session = await firstValueFrom( this.api.post('/auth/change-password', { currentPassword, newPassword }, { withCredentials: true }), ) this.auth.applySession(session) this.password.reset() state.done('Password aggiornata. Per sicurezza abbiamo chiuso le sessioni aperte sugli altri dispositivi.') } catch (err) { const error = toApiError(err) if ((error.code === 'VALIDATION_ERROR' || error.code === 'INVALID_CURRENT_PASSWORD') && applyServerErrors(this.password, error)) { state.fail('Controlla i campi evidenziati.') } else { state.fail(error.code === 'RATE_LIMITED' ? 'Troppi tentativi: attendi un minuto e riprova.' : error.message) } } } protected async setMarketing(enabled: boolean): Promise { this.consentError.set(null) this.consentBusy.set(true) try { const path = '/me/consents/marketing' const res = await firstValueFrom(enabled ? this.api.post(path, null) : this.api.delete(path)) this.consents.set(res) } catch (err) { this.consentError.set(toApiError(err).message) } finally { this.consentBusy.set(false) } } protected askDeletion(): void { const state = this.deletionState state.start() if (this.deletion.invalid) return state.stop() state.stop() this.deleteDialog()?.open() } protected async confirmDeletion(): Promise { const state = this.deletionState state.busy.set(true) try { const { password, reason } = this.deletion.getRawValue() await firstValueFrom(this.api.post('/me/delete-account', { password, ...(reason.trim() ? { reason: reason.trim() } : {}) })) this.deleteDialog()?.close() // L'account è disattivato e le sessioni chiuse: si esce senza chiamare il logout this.auth.forget() await this.router.navigate(['/login'], { queryParams: { account: 'disattivato' }, replaceUrl: true }) } catch (err) { this.deleteDialog()?.close() const error = toApiError(err) if (error.code === 'INVALID_CREDENTIALS') { this.deletion.controls.password.setErrors({ server: 'Password non corretta' }) state.fail('Password non corretta: l’account non è stato disattivato.') } else { state.fail(error.message) } } finally { state.busy.set(false) } } /** Stato di invio di un form: tentativo fatto, in corso, messaggio di esito. */ private formState() { const submitted = signal(false) const busy = signal(false) const message = signal<{ ok: boolean; text: string } | null>(null) return { submitted, busy, message, start: () => { submitted.set(true) message.set(null) busy.set(true) }, stop: () => busy.set(false), done: (text: string) => { busy.set(false) submitted.set(false) message.set({ ok: true, text }) }, fail: (text: string) => { busy.set(false) message.set({ ok: false, text }) }, } } }