Files
iti-technology/apps/private/src/app/pages/customer/profile.ts
T
Lorenzo SanesiandClaude Opus 5.5 b83ce039fb
Deploy stack su Proxmox / deploy (push) Failing after 3s
Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Monorepo npm con apps/public (Angular SSR), apps/private (Angular SPA),
apps/api (Fastify), packages/shared e packages/styles; Medusa 2.21 in medusa/;
infrastruttura Docker (Postgres PostGIS+pgvector, Redis, Caddy, backup) e
workflow Gitea Actions per il deploy via SSH a ogni push su master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jQyT6dR9czzGwqb7FkG6u
2026-10-20 09:30:48 +02:00

269 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
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<ConsentsResponse>('/me/consents') })
protected readonly consentBusy = signal(false)
protected readonly consentError = signal<string | null>(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<ConfirmDialog>('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<void> {
const state = this.detailsState
state.start()
if (this.details.invalid) return state.stop()
try {
const user = await firstValueFrom(this.api.patch<UserDto>('/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<void> {
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<SessionResponse>('/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<void> {
this.consentError.set(null)
this.consentBusy.set(true)
try {
const path = '/me/consents/marketing'
const res = await firstValueFrom(enabled ? this.api.post<ConsentsResponse>(path, null) : this.api.delete<ConsentsResponse>(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<void> {
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 })
},
}
}
}