Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Deploy stack su Proxmox / deploy (push) Failing after 3s
Deploy stack su Proxmox / deploy (push) Failing after 3s
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
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
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 })
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user