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
269 lines
9.8 KiB
TypeScript
269 lines
9.8 KiB
TypeScript
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 })
|
||
},
|
||
}
|
||
}
|
||
}
|