diff --git a/.env.example b/.env.example index 0198a86..a09c22a 100644 --- a/.env.example +++ b/.env.example @@ -126,15 +126,24 @@ SESSION_SECURE_COOKIE=false # ----------------------------------------------------------------------------- # Posta # ----------------------------------------------------------------------------- +# Il server di posta si configura di solito dall'applicazione (Impostazioni → +# Server di posta): se lì è compilato, questi valori vengono ignorati. +# Servono le email di conferma: senza posta gli utenti non possono confermare +# il loro indirizzo e il loro account non funziona. +# # In produzione: SMTP esterno. # In locale con --profile dev: MAIL_HOST=mailpit, MAIL_PORT=1025 # Per disattivare del tutto l'invio: MAIL_MAILER=log +# +# MAIL_SCHEME: vuoto (sceglie da solo: STARTTLS sulla 587, SSL sulla 465), +# oppure smtp / smtps. "tls" non è un valore valido con Laravel 11 e fa +# fallire ogni invio. MAIL_MAILER=smtp MAIL_HOST= MAIL_PORT=587 MAIL_USERNAME= MAIL_PASSWORD= -MAIL_SCHEME=tls +MAIL_SCHEME= MAIL_FROM_ADDRESS=noreply@example.org MAIL_FROM_NAME="${APP_NAME}" diff --git a/HANDOFF.md b/HANDOFF.md index 912cd09..9620b1e 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -90,6 +90,45 @@ Commit su `dev`, **nessuno pubblicato** (il push fa partire il deploy sulla demo Produzione: al 12/10/2026 non esiste ancora (il sito gira in Docker sul PC di sviluppo, più la demo da `dev`). Nessuna correzione una tantum da fare. +### Posta, contatti dei proclamatori, conferma email (12-13 ottobre 2026) + +Richieste dell'utente, con le sue scelte: + +- **Server di posta dall'app** (Impostazioni → Server di posta, rotta + `posta.edit`, `Settings\PostaElettronica`): host, porta, cifratura + (automatica / SSL / nessuna), utente, password cifrata, mittente; pulsante + "Invia email di prova". Se compilato prende il posto del `.env` + (`ConfigurazionePosta::applica()`, agganciata alla creazione del mail + manager e a ogni job della coda). Non si esporta nell'XML e l'import non la + cancella (`Setting::CAMPI_POSTA`). Nel `.env.example` `MAIL_SCHEME=tls` era + un valore non valido per Laravel 11: ora vuoto. +- **Proclamatori: email e telefono facoltativi**, cifrati come nome e cognome, + nell'export/import XML, cancellati dall'anonimizzazione. Il numero apre + WhatsApp (``, `Proclamatore::whatsappUrl()`: senza prefisso si + considera italiano). +- **Dopo l'assegnazione** si resta sulla pagina Assegna: in cima un riquadro + per ogni assegnazione appena fatta con il link del PDF, "Invia su WhatsApp" + (messaggio già scritto con il link) e la scelta "Sì, inviato / Non ancora", + che imposta `link_sent`. Resta finché non si sceglie + (`Assegna::DA_INVIARE` in sessione). +- **Conferma email obbligatoria** per tutti gli utenti, anche quelli già + esistenti (la migrazione `2026_10_13_000010` azzera `email_verified_at`). + L'admin crea l'utente con la password come prima; parte l'email di conferma + (in italiano, `VerifyEmail::toMailUsing`); il link funziona anche senza + login. Cambiare email (profilo o admin) la rende da confermare e rimanda il + link. In Utenti: stato "da confermare" e "Reinvia conferma". + Raggiungibili senza conferma: profilo, cambio password, pagina di conferma + e — per l'amministratore — il server di posta. + Emergenza: `php artisan utenti:conferma-email `. Il primo admin + creato da riga di comando nasce confermato. +- Nei test gli utenti nascono confermati (`tests/TestCase.php`, + `$confermaUtentiCreati`). + +Provato nel browser in locale con mailpit come server configurato dall'app: +email di prova e di conferma arrivate, link aperto senza login, account +sbloccato; riquadro dopo l'assegnazione con WhatsApp e scelta. Configurazione +e dati di prova rimossi dopo. + ### Revisione profonda: blocchi 2 e 3 chiusi | Problema | Correzione | diff --git a/app/Console/Commands/ConfermaEmailUtente.php b/app/Console/Commands/ConfermaEmailUtente.php new file mode 100644 index 0000000..38f5246 --- /dev/null +++ b/app/Console/Commands/ConfermaEmailUtente.php @@ -0,0 +1,44 @@ +argument('email'))->first(); + + if (! $user) { + $this->error('Nessun utente con questo indirizzo.'); + + return self::FAILURE; + } + + if ($user->hasVerifiedEmail()) { + $this->info("{$user->email} è già confermato."); + + return self::SUCCESS; + } + + $user->markEmailAsVerified(); + activity()->performedOn($user)->log('email_confermata_da_riga_di_comando'); + + $this->info("Email di {$user->email} confermata."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/CreateInitialAdmin.php b/app/Console/Commands/CreateInitialAdmin.php index 8949545..9c322b1 100644 --- a/app/Console/Commands/CreateInitialAdmin.php +++ b/app/Console/Commands/CreateInitialAdmin.php @@ -95,6 +95,11 @@ class CreateInitialAdmin extends Command 'password' => Hash::make($password), ]); + // Confermato da subito: su un'installazione nuova il server di + // posta non c'è ancora, e chi ha accesso alla riga di comando del + // server non ha bisogno di dimostrare di chi è l'indirizzo. Senza, + // il primo amministratore arriverebbe solo alla pagina della posta. + $user->markEmailAsVerified(); $user->assignRole('amministratore'); return $user; diff --git a/app/Http/Controllers/Auth/VerificaEmailController.php b/app/Http/Controllers/Auth/VerificaEmailController.php new file mode 100644 index 0000000..d6fa2a7 --- /dev/null +++ b/app/Http/Controllers/Auth/VerificaEmailController.php @@ -0,0 +1,77 @@ +user()->hasVerifiedEmail()) { + return redirect()->route('dashboard'); + } + + return view('auth.verifica-email', [ + 'user' => $request->user(), + 'postaFunzionante' => $posta->funzionante(), + ]); + } + + public function invia(Request $request) + { + $user = $request->user(); + + if ($user->hasVerifiedEmail()) { + return redirect()->route('dashboard'); + } + + try { + $user->sendEmailVerificationNotification(); + } catch (\Throwable $e) { + Log::warning('Email di conferma non inviata.', ['user' => $user->id, 'exception' => $e]); + + return back()->with('error', 'L\'email non è partita: il server di posta non risponde o rifiuta l\'invio. Avvisa un amministratore.'); + } + + return back()->with('success', "Email inviata a {$user->email}. Apri il link che contiene (controlla anche lo spam)."); + } + + public function conferma(Request $request, int $id, string $hash) + { + $user = User::find($id); + + // La firma garantisce id e scadenza; l'hash lega il link all'indirizzo + // di allora, così un link vecchio non conferma un'email cambiata dopo. + if (! $user || ! hash_equals(sha1($user->getEmailForVerification()), $hash)) { + abort(403, 'Link di conferma non valido.'); + } + + if (! $user->hasVerifiedEmail()) { + $user->markEmailAsVerified(); + event(new Verified($user)); + activity()->causedBy($user)->log('email_confermata'); + } + + $messaggio = 'Indirizzo email confermato.'; + + if ($request->user()?->is($user)) { + return redirect()->route('dashboard')->with('success', $messaggio); + } + + return redirect()->route('login')->with('success', $messaggio . ' Ora puoi accedere.'); + } +} diff --git a/app/Http/Middleware/RequirePasswordChange.php b/app/Http/Middleware/RequirePasswordChange.php index 5a6ebac..3cf4955 100644 --- a/app/Http/Middleware/RequirePasswordChange.php +++ b/app/Http/Middleware/RequirePasswordChange.php @@ -26,6 +26,9 @@ class RequirePasswordChange 'password.change', 'password.change.store', 'logout', + // Il link della mail di conferma: aperto in quel momento andrebbe + // perso, e la persona dovrebbe chiederne un altro. + 'verification.verify', ]; public function handle(Request $request, Closure $next): Response diff --git a/app/Livewire/Assegnazioni/Assegna.php b/app/Livewire/Assegnazioni/Assegna.php index 10cd991..a8101df 100644 --- a/app/Livewire/Assegnazioni/Assegna.php +++ b/app/Livewire/Assegnazioni/Assegna.php @@ -12,6 +12,9 @@ use Illuminate\Support\Facades\DB; class Assegna extends Component { + /** Chiave di sessione delle assegnazioni appena fatte, in attesa della scelta sul link. */ + public const DA_INVIARE = 'assegnazioni_da_inviare'; + public ?int $territorio_id = null; public ?int $proclamatore_id = null; public string $assigned_at = ''; @@ -107,8 +110,27 @@ class Assegna extends Component ]) ->log('assigned'); + // Si resta qui: in cima compare il riquadro con il link da mandare al + // proclamatore (WhatsApp) e la scelta "inviato / non ancora". La lista + // sta in sessione, così il riquadro resta finché non si sceglie, anche + // ricaricando la pagina o assegnando altri territori nel frattempo. + $daInviare = session(self::DA_INVIARE, []); + $daInviare[] = $assegnazione->id; + session([self::DA_INVIARE => array_values(array_unique($daInviare))]); + session()->flash('success', "Territorio {$territorio->numero} assegnato a {$proclamatore->nome_completo}."); - return $this->redirect(route('territori.show', $territorio), navigate: true); + return $this->redirect(route('assegnazioni.assegna'), navigate: true); + } + + /** La scelta nel riquadro: il link è stato mandato, oppure non ancora. */ + public function esitoInvio(int $assegnazioneId, bool $inviato): void + { + $this->authorize('territori.assign'); + + $assegnazione = Assegnazione::find($assegnazioneId); + $assegnazione?->forceFill(['link_sent' => $inviato])->saveQuietly(); + + session([self::DA_INVIARE => array_values(array_diff(session(self::DA_INVIARE, []), [$assegnazioneId]))]); } public function toggleLinkSent(int $assegnazioneId): void @@ -176,7 +198,19 @@ class Assegna extends Component // Il link scade alla soglia rientro (giorni_per_smarrito). $sogliaRientro = max(1, (int) \App\Models\Setting::getValue('giorni_per_smarrito', 120)); + // I riquadri in evidenza: solo le assegnazioni ancora aperte (una + // rientrata o cancellata nel frattempo non ha più un link da mandare). + $idDaInviare = session(self::DA_INVIARE, []); + $daInviare = $idDaInviare === [] ? collect() : Assegnazione::aperte() + ->whereIn('id', $idDaInviare) + ->with(['territorio', 'proclamatore']) + ->get(); + if ($daInviare->count() !== count($idDaInviare)) { + session([self::DA_INVIARE => $daInviare->pluck('id')->all()]); + } + return view('livewire.assegnazioni.assegna', [ + 'daInviare' => $daInviare, 'territoriDisponibili' => $territoriDisponibili, 'proclamatoriAttivi' => $proclamatoriAttivi, 'assegnazioniAperte' => $assegnazioniAperte, diff --git a/app/Livewire/Proclamatori/ProclamatoreCreate.php b/app/Livewire/Proclamatori/ProclamatoreCreate.php index 3eecb5d..b3677a0 100644 --- a/app/Livewire/Proclamatori/ProclamatoreCreate.php +++ b/app/Livewire/Proclamatori/ProclamatoreCreate.php @@ -10,6 +10,8 @@ class ProclamatoreCreate extends Component public string $nome = ''; public string $cognome = ''; public bool $attivo = true; + public string $email = ''; + public string $telefono = ''; protected function rules(): array { @@ -17,6 +19,17 @@ class ProclamatoreCreate extends Component 'nome' => 'required|string|max:100', 'cognome' => 'required|string|max:100', 'attivo' => 'boolean', + // Facoltativi: si controlla il formato solo se compilati. + 'email' => 'nullable|email|max:255', + 'telefono' => ['nullable', 'string', 'max:30', 'regex:/^\+?[0-9 ().\/-]{6,25}$/'], + ]; + } + + protected function messages(): array + { + return [ + 'email.email' => "L'indirizzo email non è valido (puoi anche lasciarlo vuoto).", + 'telefono.regex' => 'Il numero può contenere solo cifre, spazi e un "+" iniziale (puoi anche lasciarlo vuoto).', ]; } @@ -30,6 +43,8 @@ class ProclamatoreCreate extends Component 'nome' => $this->nome, 'cognome' => $this->cognome, 'attivo' => $this->attivo, + 'email' => trim($this->email) ?: null, + 'telefono' => trim($this->telefono) ?: null, ]); activity()->causedBy(auth()->user()) diff --git a/app/Livewire/Proclamatori/ProclamatoreEdit.php b/app/Livewire/Proclamatori/ProclamatoreEdit.php index 637624f..85cdc88 100644 --- a/app/Livewire/Proclamatori/ProclamatoreEdit.php +++ b/app/Livewire/Proclamatori/ProclamatoreEdit.php @@ -11,6 +11,8 @@ class ProclamatoreEdit extends Component public string $nome = ''; public string $cognome = ''; public bool $attivo = true; + public string $email = ''; + public string $telefono = ''; public function mount(Proclamatore $proclamatore) { @@ -18,6 +20,8 @@ class ProclamatoreEdit extends Component $this->nome = $proclamatore->nome; $this->cognome = $proclamatore->cognome; $this->attivo = $proclamatore->attivo; + $this->email = (string) $proclamatore->email; + $this->telefono = (string) $proclamatore->telefono; } protected function rules(): array @@ -26,6 +30,17 @@ class ProclamatoreEdit extends Component 'nome' => 'required|string|max:100', 'cognome' => 'required|string|max:100', 'attivo' => 'boolean', + // Facoltativi: si controlla il formato solo se compilati. + 'email' => 'nullable|email|max:255', + 'telefono' => ['nullable', 'string', 'max:30', 'regex:/^\+?[0-9 ().\/-]{6,25}$/'], + ]; + } + + protected function messages(): array + { + return [ + 'email.email' => "L'indirizzo email non è valido (puoi anche lasciarlo vuoto).", + 'telefono.regex' => 'Il numero può contenere solo cifre, spazi e un "+" iniziale (puoi anche lasciarlo vuoto).', ]; } @@ -39,6 +54,8 @@ class ProclamatoreEdit extends Component 'nome' => $this->nome, 'cognome' => $this->cognome, 'attivo' => $this->attivo, + 'email' => trim($this->email) ?: null, + 'telefono' => trim($this->telefono) ?: null, ]); session()->flash('success', "Proclamatore {$this->proclamatore->nome_completo} aggiornato."); diff --git a/app/Livewire/Profile.php b/app/Livewire/Profile.php index 9d07f8f..592cff9 100644 --- a/app/Livewire/Profile.php +++ b/app/Livewire/Profile.php @@ -32,10 +32,22 @@ class Profile extends Component 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)], ]); - $user->update([ + $user->fill([ 'name' => $validated['name'], 'email' => $validated['email'], ]); + $emailCambiata = $user->isDirty('email'); + $user->save(); + + // L'indirizzo nuovo va confermato (User::booted lo segna da + // confermare): finché non apre il link, l'account resta bloccato. + if ($emailCambiata) { + $user->inviaConfermaEmail() + ? session()->flash('success', "Profilo aggiornato. Conferma il nuovo indirizzo: ti abbiamo mandato un'email a {$user->email}.") + : session()->flash('error', 'Profilo aggiornato, ma l\'email di conferma non è partita: richiedila qui sotto.'); + + return $this->redirect(route('verification.notice'), navigate: false); + } session()->flash('profile_success', 'Profilo aggiornato con successo.'); diff --git a/app/Livewire/Settings/PostaElettronica.php b/app/Livewire/Settings/PostaElettronica.php new file mode 100644 index 0000000..acf0f1a --- /dev/null +++ b/app/Livewire/Settings/PostaElettronica.php @@ -0,0 +1,132 @@ +host = (string) $impostazioni->mail_host; + $this->porta = (string) ($impostazioni->mail_port ?? ''); + $this->cifratura = $impostazioni->mail_cifratura ?: ConfigurazionePosta::CIFRATURA_AUTOMATICA; + $this->utente = (string) $impostazioni->mail_username; + $this->mittente = (string) $impostazioni->mail_from_address; + $this->nomeMittente = (string) $impostazioni->mail_from_name; + } + + public function salva(): void + { + $this->host = trim($this->host); + $this->mittente = trim($this->mittente); + + $this->validate([ + 'host' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9.\-]+$/'], + 'porta' => ['nullable', 'integer', 'min:1', 'max:65535'], + 'cifratura' => ['required', Rule::in(array_keys(ConfigurazionePosta::CIFRATURE))], + 'utente' => ['nullable', 'string', 'max:255'], + 'password' => ['nullable', 'string', 'max:500'], + 'mittente' => ['required', 'email', 'max:255'], + 'nomeMittente' => ['nullable', 'string', 'max:255'], + ], [ + 'host.required' => 'Indica il server SMTP (es. smtp.gmail.com).', + 'host.regex' => 'Solo il nome del server, senza "smtp://" né porta.', + 'mittente.required' => 'Indica l\'indirizzo da cui partono le email.', + 'mittente.email' => 'L\'indirizzo del mittente non è valido.', + ]); + + $valori = [ + 'mail_host' => $this->host, + 'mail_port' => $this->porta !== '' ? (int) $this->porta : null, + 'mail_cifratura' => $this->cifratura, + 'mail_username' => trim($this->utente) ?: null, + 'mail_from_address' => $this->mittente, + 'mail_from_name' => trim($this->nomeMittente) ?: null, + ]; + if ($this->password !== '') { + $valori['mail_password'] = $this->password; + } + + Setting::instance()->update($valori); + $this->password = ''; + + activity()->causedBy(auth()->user())->log('posta_configurata'); + $this->avvisa('Server di posta salvato. Prova l\'invio con "Invia email di prova".'); + } + + public function inviaProva(ConfigurazionePosta $posta): void + { + if (! $posta->configurataNellApp()) { + $this->avvisaErrore('Salva prima il server di posta.'); + + return; + } + + try { + $posta->inviaProva(auth()->user()->email); + } catch (\RuntimeException $e) { + $this->avvisaErrore($e->getMessage()); + + return; + } + + $this->avvisa('Email di prova inviata a ' . auth()->user()->email . '. Controlla anche lo spam.'); + } + + /** Torna al server del .env. */ + public function rimuovi(): void + { + Setting::instance()->update(array_fill_keys(Setting::CAMPI_POSTA, null)); + $this->mount(); + $this->password = ''; + + activity()->causedBy(auth()->user())->log('posta_rimossa'); + $this->avvisa('Configurazione rimossa: valgono di nuovo le impostazioni del server (.env).'); + } + + public function render(ConfigurazionePosta $posta) + { + $impostazioni = Setting::instance(); + + return view('livewire.settings.posta-elettronica', [ + 'configurataNellApp' => $posta->configurataNellApp(), + 'funzionante' => $posta->funzionante(), + 'passwordSalvata' => $impostazioni->getRawOriginal('mail_password') !== null, + 'cifrature' => ConfigurazionePosta::CIFRATURE, + 'nonConfermato' => ! auth()->user()->hasVerifiedEmail(), + 'title' => 'Server di posta', + ]); + } +} diff --git a/app/Livewire/Settings/UsersIndex.php b/app/Livewire/Settings/UsersIndex.php index 5c1f87e..9e228d2 100644 --- a/app/Livewire/Settings/UsersIndex.php +++ b/app/Livewire/Settings/UsersIndex.php @@ -85,7 +85,30 @@ class UsersIndex extends Component if (! empty($this->availableRoles)) { $this->selectedRole = $this->availableRoles[0]; } - $this->avvisa('Utente creato con successo.'); + + // L'account funziona solo dopo la conferma dell'indirizzo. + if ($user->inviaConfermaEmail()) { + $this->avvisa("Utente creato. Gli è stata inviata un'email a {$user->email}: l'account funziona dopo che conferma l'indirizzo."); + } else { + $this->avvisaErrore("Utente creato, ma l'email di conferma non è partita: controlla il server di posta e usa \"Reinvia conferma\"."); + } + } + + public function reinviaConferma(int $userId): void + { + $user = User::query()->findOrFail($userId); + + if ($user->hasVerifiedEmail()) { + $this->avvisa("{$user->email} è già confermato."); + + return; + } + + if ($user->inviaConfermaEmail()) { + $this->avvisa("Email di conferma inviata a {$user->email}."); + } else { + $this->avvisaErrore('L\'email non è partita: controlla il server di posta.'); + } } public function startEdit(int $userId): void @@ -145,10 +168,24 @@ class UsersIndex extends Component $user->password = $validated['editPassword']; } + $emailCambiata = $user->isDirty('email'); $user->save(); $user->syncRoles([$validated['editSelectedRole']]); $this->cancelEdit(); + + // Indirizzo nuovo: torna da confermare (User::booted) e gli si manda + // il link, altrimenti il suo account smetterebbe di funzionare senza + // che sappia perché. + if ($emailCambiata) { + $inviata = $user->inviaConfermaEmail(); + $inviata + ? $this->avvisa("Utente aggiornato. Il nuovo indirizzo va confermato: email inviata a {$user->email}.") + : $this->avvisaErrore('Utente aggiornato, ma l\'email di conferma del nuovo indirizzo non è partita: controlla il server di posta.'); + + return; + } + $this->avvisa('Utente aggiornato con successo.'); } diff --git a/app/Livewire/Settings/XmlExchange.php b/app/Livewire/Settings/XmlExchange.php index be19099..951afa7 100644 --- a/app/Livewire/Settings/XmlExchange.php +++ b/app/Livewire/Settings/XmlExchange.php @@ -475,6 +475,13 @@ class XmlExchange extends Component Tipologia::query()->delete(); Zona::query()->delete(); AnnoTeocratico::query()->delete(); + + // Il server di posta è configurazione di questa installazione, non + // un dato da trasferire: non si esporta (la password è cifrata con + // la sua APP_KEY) e un import non deve cancellarlo — senza posta + // nessuno riceverebbe più le email di conferma. + $posta = Setting::query()->first()?->only(Setting::CAMPI_POSTA) ?? []; + Setting::query()->delete(); // The mass delete above fires no model events, so Setting's @@ -502,6 +509,10 @@ class XmlExchange extends Component ]); } + if ($posta !== []) { + Setting::instance()->update($posta); + } + // Users are UPSERTED by email, never wiped. Deleting the users // table during a restore risks locking out the administrator // performing the import (e.g. a backup that predates their account, @@ -618,6 +629,9 @@ class XmlExchange extends Component 'nome' => (string) $proclamatoreNode->nome, 'cognome' => (string) $proclamatoreNode->cognome, 'attivo' => ((int) ($proclamatoreNode->attivo ?? 1)) === 1, + // Facoltativi, e assenti nei file di prima dell'ottobre 2026. + 'email' => trim((string) ($proclamatoreNode->email ?? '')) ?: null, + 'telefono' => trim((string) ($proclamatoreNode->telefono ?? '')) ?: null, ]); $this->applyRowMeta('proclamatori', $p->id, $proclamatoreNode, true); $stats['proclamatori_importati']++; @@ -814,7 +828,7 @@ class XmlExchange extends Component ])->all(), 'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']), 'tipologie' => $this->righeEsportate(Tipologia::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']), - 'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'attivo', 'deleted_at', 'created_at', 'updated_at']), + 'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'email', 'telefono', 'attivo', 'deleted_at', 'created_at', 'updated_at']), 'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at']), 'anni_teocratici' => $this->righeEsportate(AnnoTeocratico::query()->orderBy('id'), ['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at']), 'campagne' => $this->righeEsportate(Campagna::query()->orderBy('id'), ['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at']), @@ -1249,6 +1263,8 @@ class XmlExchange extends Component $this->addLegacyIdAttribute($node, $proclamatore); $this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? '')); $this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? '')); + $this->addXmlText($node, 'email', (string) ($proclamatore['email'] ?? '')); + $this->addXmlText($node, 'telefono', (string) ($proclamatore['telefono'] ?? '')); $node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1))); $node->addChild('deleted_at', (string) ($proclamatore['deleted_at'] ?? '')); $this->addTimestampNodes($node, $proclamatore); diff --git a/app/Models/Proclamatore.php b/app/Models/Proclamatore.php index 7bbc32b..e8fc7d1 100644 --- a/app/Models/Proclamatore.php +++ b/app/Models/Proclamatore.php @@ -13,13 +13,16 @@ class Proclamatore extends Model protected $table = 'proclamatori'; - protected $fillable = ['nome', 'cognome', 'attivo']; + protected $fillable = ['nome', 'cognome', 'email', 'telefono', 'attivo']; protected function casts(): array { return [ 'nome' => 'encrypted', 'cognome' => 'encrypted', + // Facoltativi. Cifrati come nome e cognome: dati personali. + 'email' => 'encrypted', + 'telefono' => 'encrypted', 'attivo' => 'boolean', ]; } @@ -27,7 +30,7 @@ class Proclamatore extends Model public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() - ->logOnly(['attivo']) // Do NOT log nome/cognome in audit (encrypted, GDPR) + ->logOnly(['attivo']) // Do NOT log nome/cognome/email/telefono in audit (encrypted, GDPR) ->logOnlyDirty() ->dontSubmitEmptyLogs(); } @@ -40,6 +43,46 @@ class Proclamatore extends Model return trim($this->cognome . ' ' . $this->nome); } + /** + * Il numero come lo vuole WhatsApp: solo cifre, con il prefisso + * internazionale. Un numero senza prefisso si considera italiano: un + * cellulare scritto "333 1234567" diventa 393331234567. Null se non c'è un + * numero utilizzabile. + */ + public function telefonoInternazionale(): ?string + { + $grezzo = trim((string) $this->telefono); + if ($grezzo === '') { + return null; + } + + $cifre = preg_replace('/\D+/', '', $grezzo); + + if (str_starts_with($cifre, '00') && ! str_starts_with($grezzo, '+')) { + $cifre = substr($cifre, 2); + } elseif (! str_starts_with($grezzo, '+') && (! str_starts_with($cifre, '39') || strlen($cifre) <= 10)) { + // Senza prefisso: cellulari (3…) e fissi (0…) italiani. Un + // "39…" di 11 cifre o più ha già il prefisso, scritto senza "+". + $cifre = '39' . $cifre; + } + + return strlen($cifre) >= 8 && strlen($cifre) <= 15 ? $cifre : null; + } + + /** + * Il link che apre una chat WhatsApp con il proclamatore, con un messaggio + * già scritto se c'è. Null se non ha un numero utilizzabile. + */ + public function whatsappUrl(?string $messaggio = null): ?string + { + $numero = $this->telefonoInternazionale(); + if ($numero === null) { + return null; + } + + return 'https://wa.me/' . $numero . ($messaggio !== null && $messaggio !== '' ? '?text=' . rawurlencode($messaggio) : ''); + } + public function assegnazioni() { return $this->hasMany(Assegnazione::class, 'proclamatore_id'); @@ -63,6 +106,8 @@ class Proclamatore extends Model { $this->nome = 'Anonimo'; $this->cognome = 'Proclamatore #' . $this->id; + $this->email = null; + $this->telefono = null; $this->attivo = false; $this->save(); $this->delete(); // soft delete diff --git a/app/Models/Setting.php b/app/Models/Setting.php index 60eab2c..75634f9 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -20,8 +20,31 @@ class Setting extends Model 'pdf_viewer_show_download', 'audit_retention_days', 'setup_completed', + 'mail_host', + 'mail_port', + 'mail_cifratura', + 'mail_username', + 'mail_password', + 'mail_from_address', + 'mail_from_name', ]; + /** + * Il server di posta: configurazione di questa installazione, non dati. + * Non si esporta e un import non la tocca (vedi XmlExchange). + */ + public const CAMPI_POSTA = [ + 'mail_host', + 'mail_port', + 'mail_cifratura', + 'mail_username', + 'mail_password', + 'mail_from_address', + 'mail_from_name', + ]; + + protected $hidden = ['mail_password']; + protected function casts(): array { return [ @@ -33,6 +56,8 @@ class Setting extends Model 'home_limit_list' => 'integer', 'assignment_link_ttl_hours' => 'integer', 'audit_retention_days' => 'integer', + 'mail_port' => 'integer', + 'mail_password' => 'encrypted', ]; } diff --git a/app/Models/User.php b/app/Models/User.php index 856a712..74df4b0 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -9,7 +10,12 @@ use Spatie\Permission\Traits\HasRoles; use Spatie\Activitylog\Traits\LogsActivity; use Spatie\Activitylog\LogOptions; -class User extends Authenticatable +/** + * MustVerifyEmail: l'account funziona solo con l'indirizzo confermato + * (middleware `verified`, VerificaEmailController). Chi cambia email — dal + * profilo o perché la cambia l'amministratore — torna da confermare. + */ +class User extends Authenticatable implements MustVerifyEmail { use HasFactory, Notifiable, HasRoles, LogsActivity; @@ -41,4 +47,32 @@ class User extends Authenticatable ->logOnlyDirty() ->dontSubmitEmptyLogs(); } + + protected static function booted(): void + { + // Un indirizzo nuovo va confermato di nuovo, chiunque lo cambi. + static::updating(function (self $utente) { + if ($utente->isDirty('email') && ! $utente->isDirty('email_verified_at')) { + $utente->email_verified_at = null; + } + }); + } + + /** + * Manda l'email di conferma; false se il server di posta non risponde o + * rifiuta. Chi chiama decide cosa dire: l'utente è comunque salvato, e + * l'email si può richiedere di nuovo. + */ + public function inviaConfermaEmail(): bool + { + try { + $this->sendEmailVerificationNotification(); + } catch (\Throwable $e) { + report($e); + + return false; + } + + return true; + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 00ea3d5..3f71c1c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,10 @@ namespace App\Providers; use App\Models\Setting; +use App\Services\ConfigurazionePosta; +use Illuminate\Auth\Middleware\EnsureEmailIsVerified; +use Illuminate\Auth\Notifications\VerifyEmail; +use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Queue; @@ -24,7 +28,42 @@ class AppServiceProvider extends ServiceProvider // un'ora: senza questo, ogni job usava le impostazioni lette dal primo, // anche dopo che l'amministratore le aveva cambiate dal browser o un // ripristino le aveva sostituite. - Queue::before(fn () => Setting::flushCache()); + Queue::before(function () { + Setting::flushCache(); + + // Anche il server di posta: cambiato dal browser, il worker + // continuava a usare quello del primo invio. + if ($this->app->resolved('mail.manager')) { + app(ConfigurazionePosta::class)->applica(); + } + }); + + // L'email di conferma in italiano. Il link resta quello di Laravel + // (firmato, scade dopo auth.verification.expire minuti) e porta a + // VerificaEmailController::conferma, che funziona anche senza login. + VerifyEmail::toMailUsing(function ($utente, string $link) { + $minuti = (int) config('auth.verification.expire', 60); + + return (new MailMessage) + ->subject('Conferma il tuo indirizzo email — TerManager2') + ->greeting('Ciao ' . $utente->name . ',') + ->line('per usare TerManager2 conferma che questo indirizzo email è tuo.') + ->action('Conferma l\'indirizzo', $link) + ->line("Il link vale {$minuti} minuti. Se è scaduto, entra in TerManager2 e chiedine uno nuovo.") + ->line('Se non sai di cosa si tratta, ignora questa email.') + ->salutation('TerManager2'); + }); + + // Le richieste di Livewire non ripassano dal gruppo di rotte della + // pagina: senza, una scheda aperta prima del blocco continuerebbe a + // lavorare con un'email non confermata. + Livewire::addPersistentMiddleware([EnsureEmailIsVerified::class]); + + // Il server di posta configurato in Impostazioni prende il posto di + // quello del .env, appena Laravel crea il sistema di posta. + $this->app->afterResolving('mail.manager', function ($manager) { + app(ConfigurazionePosta::class)->applica($manager); + }); // Pin generated URLs to APP_URL. // diff --git a/app/Services/ConfigurazionePosta.php b/app/Services/ConfigurazionePosta.php new file mode 100644 index 0000000..4022b30 --- /dev/null +++ b/app/Services/ConfigurazionePosta.php @@ -0,0 +1,121 @@ + 'Automatica (STARTTLS se il server la offre, di solito porta 587)', + self::CIFRATURA_SSL => 'SSL/TLS dall\'inizio (di solito porta 465)', + self::CIFRATURA_NESSUNA => 'Nessuna (solo per server interni)', + ]; + + /** C'è un server di posta configurato dall'applicazione. */ + public function configurataNellApp(): bool + { + return trim((string) Setting::instance()->mail_host) !== ''; + } + + /** + * Le email partono davvero: server dall'app, oppure un mailer del .env che + * non sia "log" o "array" (che le scrivono nel log o le buttano). + */ + public function funzionante(): bool + { + if ($this->configurataNellApp()) { + return true; + } + + $mailer = (string) config('mail.default'); + + return ! in_array($mailer, ['log', 'array'], true) + && ($mailer !== 'smtp' || trim((string) config('mail.mailers.smtp.host')) !== ''); + } + + /** Porta la configurazione dell'app nel sistema di posta di Laravel. */ + public function applica(?MailManager $manager = null): void + { + try { + $impostazioni = Setting::instance(); + } catch (\Throwable) { + // Database non ancora pronto (prima installazione, migrazioni in + // corso): resta il .env. + return; + } + + if (trim((string) $impostazioni->mail_host) === '') { + return; + } + + try { + $password = $impostazioni->mail_password; + } catch (DecryptException) { + // Cifrata con un'altra APP_KEY: meglio tentare senza password (e + // ricevere dal server un errore che dice "autenticazione") che + // far cadere ogni pagina che tocca la posta. + $password = null; + } + + $cifratura = $impostazioni->mail_cifratura ?: self::CIFRATURA_AUTOMATICA; + + config([ + 'mail.default' => 'smtp', + 'mail.mailers.smtp' => [ + 'transport' => 'smtp', + 'scheme' => $cifratura === self::CIFRATURA_SSL ? 'smtps' : 'smtp', + 'host' => $impostazioni->mail_host, + 'port' => $impostazioni->mail_port ?: ($cifratura === self::CIFRATURA_SSL ? 465 : 587), + 'username' => $impostazioni->mail_username ?: null, + 'password' => $password ?: null, + 'timeout' => 20, + 'local_domain' => config('mail.mailers.smtp.local_domain'), + // Senza, Symfony prova STARTTLS da solo appena il server lo offre. + 'auto_tls' => $cifratura !== self::CIFRATURA_NESSUNA, + ], + 'mail.from.address' => $impostazioni->mail_from_address ?: config('mail.from.address'), + 'mail.from.name' => $impostazioni->mail_from_name ?: ($impostazioni->congregazione_nome ?: config('mail.from.name')), + ]); + + // Un mailer già creato tiene la configurazione con cui è nato. + ($manager ?? app('mail.manager'))->purge('smtp'); + } + + /** + * Invia l'email di prova. Lancia RuntimeException con un messaggio + * leggibile se il server rifiuta o non risponde. + */ + public function inviaProva(string $destinatario): void + { + $this->applica(); + + try { + Mail::raw( + "Questa è un'email di prova di TerManager2.\n\nSe la stai leggendo, il server di posta è configurato correttamente: " + . 'gli utenti riceveranno le email per confermare il loro indirizzo.', + fn ($messaggio) => $messaggio->to($destinatario)->subject('TerManager2 — email di prova'), + ); + } catch (\Throwable $e) { + throw new RuntimeException('Il server di posta ha rifiutato l\'invio: ' . $e->getMessage(), 0, $e); + } + } +} diff --git a/database/migrations/2026_10_13_000010_posta_contatti_e_conferma_email.php b/database/migrations/2026_10_13_000010_posta_contatti_e_conferma_email.php new file mode 100644 index 0000000..1a7cd60 --- /dev/null +++ b/database/migrations/2026_10_13_000010_posta_contatti_e_conferma_email.php @@ -0,0 +1,59 @@ +string('mail_host')->nullable(); + $table->unsignedInteger('mail_port')->nullable(); + $table->string('mail_cifratura', 10)->nullable(); + $table->string('mail_username')->nullable(); + $table->text('mail_password')->nullable(); + $table->string('mail_from_address')->nullable(); + $table->string('mail_from_name')->nullable(); + }); + + Schema::table('proclamatori', function (Blueprint $table) { + // text: il valore cifrato è molto più lungo dell'indirizzo. + $table->text('email')->nullable(); + $table->text('telefono')->nullable(); + }); + + DB::table('users')->update(['email_verified_at' => null]); + } + + public function down(): void + { + Schema::table('settings', function (Blueprint $table) { + $table->dropColumn([ + 'mail_host', 'mail_port', 'mail_cifratura', 'mail_username', + 'mail_password', 'mail_from_address', 'mail_from_name', + ]); + }); + + Schema::table('proclamatori', function (Blueprint $table) { + $table->dropColumn(['email', 'telefono']); + }); + } +}; diff --git a/resources/css/app.css b/resources/css/app.css index 587c06c..60cc2b4 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -557,6 +557,84 @@ html { scroll-behavior: smooth; } background: var(--tm-superficie-alt); } +/* ─── Numero di telefono che apre WhatsApp ───────────────────────────────── */ + +.tm-whatsapp { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: 1px solid #b7e1c6; + border-radius: 999px; + background: #eaf7ef; + font-size: 13px; + font-weight: 600; + color: #17703f; + text-decoration: none; + white-space: nowrap; +} +.tm-whatsapp:hover { background: #d8f0e1; } +.tm-whatsapp:focus-visible { outline: 2px solid #17703f; outline-offset: 2px; } +.tm-whatsapp svg { width: 16px; height: 16px; flex: none; } +.tm-whatsapp-compatto { padding: 5px; } +.tm-whatsapp-grande { + padding: 10px 18px; + font-size: 15px; +} +.tm-whatsapp-grande svg { width: 20px; height: 20px; } +.tm-whatsapp-numero { font-weight: 500; opacity: .75; } + +/* ─── Assegnazione appena fatta: il link da mandare ──────────────────────── */ + +.tm-da-inviare { + display: flex; + flex-direction: column; + gap: 14px; + max-width: 48rem; + margin-bottom: 20px; + padding: 18px 20px; + border: 2px solid var(--tm-accento); + border-radius: var(--tm-raggio); + background: var(--tm-accento-tenue); + box-shadow: var(--tm-ombra-2); +} +.tm-da-inviare-titolo { + margin: 0; + font-size: 16px; + font-weight: 700; + color: var(--tm-accento-scuro); +} +.tm-da-inviare-link { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} +.tm-da-inviare-link code { + flex: 1 1 260px; + min-width: 0; + padding: 8px 12px; + border: 1px solid var(--tm-bordo); + border-radius: var(--tm-raggio-interno); + background: var(--tm-superficie); + font-size: 13px; + color: var(--tm-testo); + word-break: break-all; + cursor: text; +} +.tm-da-inviare-azioni { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } +.tm-da-inviare-scelta { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px; + padding-top: 12px; + border-top: 1px solid var(--tm-bordo); +} +.tm-da-inviare-scelta p { margin: 0; font-size: 14px; font-weight: 600; color: var(--tm-testo); } +.tm-da-inviare-scelta > div { display: flex; flex-wrap: wrap; gap: 8px; } + /* ─── Ricerca e filtri sopra un elenco ───────────────────────────────────── */ .tm-filtri { diff --git a/resources/views/auth/verifica-email.blade.php b/resources/views/auth/verifica-email.blade.php new file mode 100644 index 0000000..2f7e0a9 --- /dev/null +++ b/resources/views/auth/verifica-email.blade.php @@ -0,0 +1,50 @@ + +
+
+

Conferma il tuo indirizzo email

+

+ Account: {{ $user->email }} +

+
+ + @if(session('success')) +
+ {{ session('success') }} +
+ @endif + @if(session('error')) +
+ {{ session('error') }} +
+ @endif + +

+ Per usare TerManager2 devi confermare che questo indirizzo è tuo: apri il link nell'email che ti + abbiamo mandato. Se non l'hai ricevuta, o il link è scaduto, chiedine una nuova. +

+ + @unless($postaFunzionante) +
+ Il server di posta non è ancora configurato: l'email non può partire. + @can('settings.manage') + Configuralo qui. + @else + Avvisa un amministratore. + @endcan +
+ @endunless + +
+ @csrf + Inviami l'email di conferma +
+ +
+ L'indirizzo è sbagliato? Correggilo nel profilo. +
+ @csrf + Esci +
+
+
+
diff --git a/resources/views/components/whatsapp.blade.php b/resources/views/components/whatsapp.blade.php new file mode 100644 index 0000000..f1d8fa1 --- /dev/null +++ b/resources/views/components/whatsapp.blade.php @@ -0,0 +1,26 @@ +{{-- + Il numero di un proclamatore che, cliccato, apre WhatsApp (wa.me: app sul + telefono, WhatsApp Web sul computer). Niente se il numero manca o non è + utilizzabile. + + proclamatore: il Proclamatore. + messaggio: testo già scritto nella chat (facoltativo). + compatto: solo l'icona, per le righe di un elenco. +--}} +@props(['proclamatore', 'messaggio' => null, 'compatto' => false]) + +@php($url = $proclamatore?->whatsappUrl($messaggio)) + +@if($url) + merge(['class' => 'tm-whatsapp' . ($compatto ? ' tm-whatsapp-compatto' : '')]) }} + title="Scrivi su WhatsApp a {{ $proclamatore->nome_completo }}" + @if($compatto) aria-label="Scrivi su WhatsApp a {{ $proclamatore->nome_completo }}" @endif> + + @unless($compatto) + {{ $proclamatore->telefono }} + @endunless + +@elseif(! $compatto && trim((string) $proclamatore?->telefono) !== '') + {{ $proclamatore->telefono }} +@endif diff --git a/resources/views/livewire/assegnazioni/assegna.blade.php b/resources/views/livewire/assegnazioni/assegna.blade.php index 96a3ff3..a0fc2d0 100644 --- a/resources/views/livewire/assegnazioni/assegna.blade.php +++ b/resources/views/livewire/assegnazioni/assegna.blade.php @@ -8,6 +8,69 @@

Questa funzione consente un'assegnazione arbitraria, indipendente dalle tre schede della Home.

+ {{-- Appena assegnato: il link da mandare al proclamatore, in evidenza, e + la scelta "inviato / non ancora". Resta finché non si sceglie + (Assegna::DA_INVIARE in sessione). --}} + @foreach($daInviare as $nuova) + @php + $link = $nuova->shortPdfUrl(); + $messaggio = $link + ? "Ciao {$nuova->proclamatore->nome}, ti è stato assegnato il territorio N° {$nuova->territorio->numero}. Ecco la piantina: {$link}" + : null; + $whatsapp = $messaggio ? $nuova->proclamatore->whatsappUrl($messaggio) : null; + @endphp +
+

+ Territorio N° {{ $nuova->territorio->numero }} assegnato a {{ $nuova->proclamatore->nome_completo }} +

+ + @if($link) + + +
+ @if($whatsapp) + + + Invia su WhatsApp + {{ $nuova->proclamatore->telefono }} + + @else +

+ {{ $nuova->proclamatore->nome_completo }} non ha un numero di telefono: copia il link e mandalo come preferisci. + @can('settings.manage') + Aggiungi il numero + @endcan +

+ @endif +
+ +
+

Il link è stato inviato?

+
+ Sì, inviato + Non ancora +
+
+ @else +

+ Il territorio non ha un PDF: non c'è un link da mandare. Caricalo dalla scheda del territorio. +

+
+
+ Ho capito +
+
+ @endif +
+ @endforeach +
{{-- Toggle form --}} @@ -158,6 +221,11 @@ + @if($a->proclamatore) + + @endif + {{-- Flag Link Inviato --}} {{-- Interruttore di stato, non un comando: lo stato "inviato" si legge dalla variante (primary quando è acceso) e diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index a41e571..3b2c852 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -5,6 +5,14 @@

Accedi per continuare

+ {{-- Per esempio dopo aver aperto il link di conferma dell'email senza + essere entrati. --}} + @if(session('success')) +
+ {{ session('success') }} +
+ @endif +
@csrf
diff --git a/resources/views/livewire/proclamatori/proclamatore-form.blade.php b/resources/views/livewire/proclamatori/proclamatore-form.blade.php index cfa076d..637992c 100644 --- a/resources/views/livewire/proclamatori/proclamatore-form.blade.php +++ b/resources/views/livewire/proclamatori/proclamatore-form.blade.php @@ -21,6 +21,22 @@ @error('nome')

{{ $message }}

@enderror
+
+ + +

Facoltativo. Serve a mandare il link del territorio su WhatsApp. Senza prefisso si considera un numero italiano.

+ @error('telefono')

{{ $message }}

@enderror +
+ +
+ + +

Facoltativa.

+ @error('email')

{{ $message }}

@enderror +
+
diff --git a/resources/views/livewire/proclamatori/proclamatore-index.blade.php b/resources/views/livewire/proclamatori/proclamatore-index.blade.php index 6686fe6..4baad7e 100644 --- a/resources/views/livewire/proclamatori/proclamatore-index.blade.php +++ b/resources/views/livewire/proclamatori/proclamatore-index.blade.php @@ -62,6 +62,7 @@ {{ $proclamatore->assegnazioni_aperte_count ?? $proclamatore->assegnazioni()->aperte()->count() }}
+ diff --git a/resources/views/livewire/proclamatori/proclamatore-show.blade.php b/resources/views/livewire/proclamatori/proclamatore-show.blade.php index 4950450..7d8fdad 100644 --- a/resources/views/livewire/proclamatori/proclamatore-show.blade.php +++ b/resources/views/livewire/proclamatori/proclamatore-show.blade.php @@ -6,6 +6,14 @@ Torna alla lista

{{ $proclamatore->nome_completo }}

+ @if($proclamatore->telefono || $proclamatore->email) +
+ + @if($proclamatore->email) + {{ $proclamatore->email }} + @endif +
+ @endif
@can('settings.manage') diff --git a/resources/views/livewire/settings/posta-elettronica.blade.php b/resources/views/livewire/settings/posta-elettronica.blade.php new file mode 100644 index 0000000..f51b824 --- /dev/null +++ b/resources/views/livewire/settings/posta-elettronica.blade.php @@ -0,0 +1,105 @@ +
+
+

Server di posta

+

+ Da qui partono le email per confermare l'indirizzo degli utenti: finché non lo confermano, il loro + account non funziona. Serve un account di posta che permetta l'invio via SMTP. +

+
+ + @if($nonConfermato) +
+ Il tuo indirizzo non è ancora confermato: il resto dell'applicazione è bloccato finché non lo fai. + Configura qui la posta, poi richiedi l'email di conferma. +
+ @endif + +
+ @if($configurataNellApp) + In uso il server configurato qui sotto. + @elseif($funzionante) + In uso il server indicato nelle impostazioni del server (.env). Compilando questa pagina lo sostituisci. + @else + Nessun server di posta: le email non partono e nessun utente può confermare il proprio indirizzo. + @endif +
+ +
+ +
+
+ + + @error('host')

{{ $message }}

@enderror +
+
+ + + @error('porta')

{{ $message }}

@enderror +
+
+ +
+ + + @error('cifratura')

{{ $message }}

@enderror +
+ +
+
+ + + @error('utente')

{{ $message }}

@enderror +
+
+ + +

Conservata cifrata. Con Gmail serve una "password per le app".

+ @error('password')

{{ $message }}

@enderror +
+
+ +
+
+ + + @error('mittente')

{{ $message }}

@enderror +
+
+ + + @error('nomeMittente')

{{ $message }}

@enderror +
+
+ +
+ Salva + + Invia email di prova + Invio… + + @if($configurataNellApp) + + Rimuovi + + @endif +
+

L'email di prova arriva al tuo indirizzo, {{ auth()->user()->email }}.

+ +
+
diff --git a/resources/views/livewire/settings/settings-edit.blade.php b/resources/views/livewire/settings/settings-edit.blade.php index 93f1f28..cbb6706 100644 --- a/resources/views/livewire/settings/settings-edit.blade.php +++ b/resources/views/livewire/settings/settings-edit.blade.php @@ -13,6 +13,14 @@

Qui trovi import PDF territori, conversione legacy SQL, import XML ed export XML.

+
+

Server di posta

+
+ Configura la posta +
+

Serve alle email con cui gli utenti confermano il loro indirizzo.

+
+
diff --git a/resources/views/livewire/settings/users-index.blade.php b/resources/views/livewire/settings/users-index.blade.php index f44a3b1..7feb2e0 100644 --- a/resources/views/livewire/settings/users-index.blade.php +++ b/resources/views/livewire/settings/users-index.blade.php @@ -1,7 +1,11 @@

Utenti

-

Crea utenti e assegna un ruolo applicativo.

+

+ Crea utenti e assegna un ruolo applicativo. Ogni nuovo utente riceve un'email per confermare il suo + indirizzo: finché non lo conferma, l'account non funziona. + Server di posta +

@@ -106,7 +110,15 @@ @else {{ $user->name }} - {{ $user->email }} + + {{ $user->email }} + @if($user->hasVerifiedEmail()) + confermata + @else + da confermare + @endif +
@forelse($user->roles as $role) @@ -119,6 +131,12 @@
Modifica + @unless($user->hasVerifiedEmail()) + + Reinvia conferma + + @endunless {{-- wire:confirm come nel resto dell'app, al posto di un onclick che fermava la propagazione a mano. --}} group(function () { +// Il link della mail di conferma: firmato e con scadenza, funziona anche +// senza essere entrati (lo si apre spesso dal telefono). +Route::get('conferma-email/{id}/{hash}', [VerificaEmailController::class, 'conferma']) + ->middleware(['signed', 'throttle:6,1']) + ->whereNumber('id') + ->name('verification.verify'); - // Home Dashboard - Route::get('/', Home::class)->name('dashboard'); +// Raggiungibili da chi è entrato ma non ha ancora confermato l'email: senza +// queste non potrebbe confermarla, correggere un indirizzo sbagliato, o — +// se è l'amministratore — configurare la posta che deve mandargli il link. +Route::middleware('auth')->group(function () { // Profilo personale (ogni utente loggato) Route::get('profilo', Profile::class)->name('profile'); @@ -81,6 +90,21 @@ Route::middleware('auth')->group(function () { Route::get('cambia-password', [PasswordChangeController::class, 'show'])->name('password.change'); Route::post('cambia-password', [PasswordChangeController::class, 'store'])->name('password.change.store'); + Route::get('conferma-email', [VerificaEmailController::class, 'avviso'])->name('verification.notice'); + Route::post('conferma-email', [VerificaEmailController::class, 'invia']) + ->middleware('throttle:6,1') + ->name('verification.send'); + + Route::get('impostazioni/posta', PostaElettronica::class) + ->middleware('permission:settings.manage') + ->name('posta.edit'); +}); + +Route::middleware(['auth', 'verified'])->group(function () { + + // Home Dashboard + Route::get('/', Home::class)->name('dashboard'); + // Territori — visualizzazione (lista + dettaglio) per tutti gli utenti // operativi, cioè chi può assegnare i territori. Route::middleware('permission:territori.assign')->group(function () { diff --git a/tests/Feature/ConfermaEmailTest.php b/tests/Feature/ConfermaEmailTest.php new file mode 100644 index 0000000..9a55879 --- /dev/null +++ b/tests/Feature/ConfermaEmailTest.php @@ -0,0 +1,174 @@ +withoutVite(); + $this->seed(RolesAndPermissionsSeeder::class); + Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']); + } + + private function utente(string $ruolo = 'operatore', bool $confermato = false): User + { + $utente = User::create([ + 'name' => 'Mario Rossi', + 'email' => $ruolo . '@example.test', + 'password' => 'password-di-prova', + ]); + $utente->assignRole($ruolo); + + if ($confermato) { + $utente->markEmailAsVerified(); + } + + return $utente->fresh(); + } + + private function linkDiConferma(User $utente, ?string $email = null): string + { + return URL::temporarySignedRoute('verification.verify', now()->addHour(), [ + 'id' => $utente->id, + 'hash' => sha1($email ?? $utente->email), + ]); + } + + public function test_senza_conferma_l_account_non_funziona(): void + { + $utente = $this->utente(); + + $this->actingAs($utente)->get(route('dashboard'))->assertRedirect(route('verification.notice')); + $this->actingAs($utente)->get(route('territori.index'))->assertRedirect(route('verification.notice')); + $this->actingAs($utente)->get(route('verification.notice'))->assertOk()->assertSee('Conferma il tuo indirizzo email'); + $this->actingAs($utente)->get(route('profile'))->assertOk(); + } + + public function test_un_amministratore_non_confermato_puo_configurare_la_posta(): void + { + $admin = $this->utente('amministratore'); + + $this->actingAs($admin)->get(route('settings.edit'))->assertRedirect(route('verification.notice')); + $this->actingAs($admin)->get(route('posta.edit'))->assertOk()->assertSee('Server di posta'); + } + + public function test_il_link_conferma_anche_senza_essere_entrati(): void + { + $utente = $this->utente(); + + $this->get($this->linkDiConferma($utente))->assertRedirect(route('login')); + + $this->assertTrue($utente->fresh()->hasVerifiedEmail()); + $this->actingAs($utente->fresh())->get(route('dashboard'))->assertOk(); + } + + public function test_un_link_alterato_o_di_un_altro_indirizzo_non_conferma(): void + { + $utente = $this->utente(); + + $link = $this->linkDiConferma($utente); + $this->get(str_replace('/' . $utente->id . '/', '/999/', $link))->assertForbidden(); + + $this->get($this->linkDiConferma($utente, 'vecchio@example.test'))->assertForbidden(); + + $this->assertFalse($utente->fresh()->hasVerifiedEmail()); + } + + public function test_richiedere_l_email_la_manda(): void + { + Notification::fake(); + $utente = $this->utente(); + + $this->actingAs($utente)->post(route('verification.send'))->assertSessionHas('success'); + + Notification::assertSentTo($utente, VerifyEmail::class); + } + + public function test_l_amministratore_crea_un_utente_e_gli_parte_la_conferma(): void + { + Notification::fake(); + $admin = $this->utente('amministratore', confermato: true); + + Livewire::actingAs($admin)->test(UsersIndex::class) + ->set('name', 'Giulia Bianchi') + ->set('email', 'giulia@example.test') + ->set('password', 'password-di-prova') + ->set('password_confirmation', 'password-di-prova') + ->set('selectedRole', 'operatore') + ->call('createUser') + ->assertHasNoErrors() + ->assertDispatched('avviso'); + + $nuovo = User::where('email', 'giulia@example.test')->sole(); + $this->assertFalse($nuovo->hasVerifiedEmail()); + Notification::assertSentTo($nuovo, VerifyEmail::class); + } + + public function test_cambiare_email_la_rende_da_confermare(): void + { + Notification::fake(); + $utente = $this->utente(confermato: true); + + Livewire::actingAs($utente)->test(Profile::class) + ->set('name', 'Mario Rossi') + ->set('email', 'nuovo@example.test') + ->call('updateProfile') + ->assertRedirect(route('verification.notice')); + + $this->assertFalse($utente->fresh()->hasVerifiedEmail()); + Notification::assertSentTo($utente->fresh(), VerifyEmail::class); + } + + public function test_cambiare_solo_il_nome_non_tocca_la_conferma(): void + { + $utente = $this->utente(confermato: true); + + Livewire::actingAs($utente)->test(Profile::class) + ->set('name', 'Mario Verdi') + ->set('email', $utente->email) + ->call('updateProfile'); + + $this->assertTrue($utente->fresh()->hasVerifiedEmail()); + } + + public function test_il_comando_di_emergenza_conferma_un_utente(): void + { + $utente = $this->utente(); + + $this->artisan('utenti:conferma-email', ['email' => $utente->email])->assertSuccessful(); + + $this->assertTrue($utente->fresh()->hasVerifiedEmail()); + } + + public function test_il_primo_amministratore_da_riga_di_comando_nasce_confermato(): void + { + $this->artisan('app:create-initial-admin', [ + '--name' => 'Admin', + '--email' => 'primo@example.test', + '--password' => 'password-di-prova', + ])->assertSuccessful(); + + $this->assertTrue(User::where('email', 'primo@example.test')->sole()->hasVerifiedEmail()); + } +} diff --git a/tests/Feature/ContattiProclamatoriTest.php b/tests/Feature/ContattiProclamatoriTest.php new file mode 100644 index 0000000..0dc4f11 --- /dev/null +++ b/tests/Feature/ContattiProclamatoriTest.php @@ -0,0 +1,210 @@ +withoutVite(); + Storage::fake('public'); + $this->seed(RolesAndPermissionsSeeder::class); + Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']); + AnnoTeocratico::create(['label' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-08-31']); + + $this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']); + $this->admin->assignRole('amministratore'); + } + + // ─── Contatti ─────────────────────────────────────────────── + + public function test_email_e_telefono_sono_facoltativi_e_cifrati(): void + { + Livewire::actingAs($this->admin)->test(ProclamatoreCreate::class) + ->set('nome', 'Mario')->set('cognome', 'Rossi') + ->call('save') + ->assertHasNoErrors(); + + Livewire::actingAs($this->admin)->test(ProclamatoreCreate::class) + ->set('nome', 'Anna')->set('cognome', 'Verdi') + ->set('email', 'anna@example.test') + ->set('telefono', '333 123 4567') + ->call('save') + ->assertHasNoErrors(); + + $anna = Proclamatore::all()->first(fn ($p) => $p->nome === 'Anna'); + $this->assertSame('anna@example.test', $anna->email); + $this->assertSame('333 123 4567', $anna->telefono); + + $riga = DB::table('proclamatori')->where('id', $anna->id)->first(); + $this->assertStringNotContainsString('anna@', $riga->email); + $this->assertStringNotContainsString('333', $riga->telefono); + } + + public function test_un_email_sbagliata_si_segnala_ma_si_puo_lasciare_vuota(): void + { + Livewire::actingAs($this->admin)->test(ProclamatoreCreate::class) + ->set('nome', 'Mario')->set('cognome', 'Rossi') + ->set('email', 'non-una-email') + ->call('save') + ->assertHasErrors('email'); + + $this->assertSame(0, Proclamatore::count()); + } + + #[DataProvider('numeri')] + public function test_il_numero_diventa_internazionale_per_whatsapp(string $scritto, ?string $atteso): void + { + $p = new Proclamatore(['nome' => 'A', 'cognome' => 'B', 'telefono' => $scritto]); + + $this->assertSame($atteso, $p->telefonoInternazionale()); + $this->assertSame($atteso ? 'https://wa.me/' . $atteso : null, $p->whatsappUrl()); + } + + public static function numeri(): array + { + return [ + 'cellulare italiano' => ['333 123 4567', '393331234567'], + 'con +39' => ['+39 333 1234567', '393331234567'], + 'con 0039' => ['0039 3331234567', '393331234567'], + '39 senza +' => ['393331234567', '393331234567'], + 'cellulare che inizia con 39' => ['392 1234567', '393921234567'], + 'fisso italiano' => ['06 12345678', '390612345678'], + 'estero' => ['+44 7911 123456', '447911123456'], + 'troppo corto' => ['123', null], + 'vuoto' => ['', null], + ]; + } + + public function test_il_messaggio_whatsapp_contiene_il_link(): void + { + $p = new Proclamatore(['nome' => 'A', 'cognome' => 'B', 'telefono' => '3331234567']); + + $this->assertSame( + 'https://wa.me/393331234567?text=' . rawurlencode('Ecco: https://x.test/p/AB12'), + $p->whatsappUrl('Ecco: https://x.test/p/AB12'), + ); + } + + public function test_anonimizzare_cancella_anche_i_contatti(): void + { + $p = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true, 'email' => 'm@example.test', 'telefono' => '3331234567']); + + $p->anonimizza(); + + $p = Proclamatore::withTrashed()->find($p->id); + $this->assertNull($p->email); + $this->assertNull($p->telefono); + } + + public function test_l_xml_porta_con_se_i_contatti(): void + { + $this->actingAs($this->admin); + Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true, 'email' => 'm@example.test', 'telefono' => '3331234567']); + + $xml = (new XmlExchange())->currentXmlBackup(); + $this->assertArrayNotHasKey('error', (new XmlExchange())->importXmlFromContent($xml)); + + $p = Proclamatore::sole(); + $this->assertSame('m@example.test', $p->email); + $this->assertSame('3331234567', $p->telefono); + } + + // ─── Dopo l'assegnazione ──────────────────────────────────── + + private function assegna(Proclamatore $proclamatore, Territorio $territorio) + { + return Livewire::actingAs($this->admin)->test(Assegna::class) + ->set('territorio_id', $territorio->id) + ->set('proclamatore_id', $proclamatore->id) + ->set('assigned_at', now()->format('Y-m-d')) + ->call('save'); + } + + private function territorioConPdf(string $numero = '7'): Territorio + { + Storage::disk('public')->put("territori-pdf/t{$numero}.pdf", '%PDF-1.4'); + + return Territorio::create(['numero' => $numero, 'attivo' => true, 'pdf_path' => "territori-pdf/t{$numero}.pdf"]); + } + + public function test_dopo_l_assegnazione_compare_il_link_da_mandare_su_whatsapp(): void + { + $mario = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true, 'telefono' => '333 1234567']); + $territorio = $this->territorioConPdf(); + + $this->assegna($mario, $territorio) + ->assertHasNoErrors() + ->assertRedirect(route('assegnazioni.assegna')); + + $assegnazione = Assegnazione::sole(); + $this->assertSame([$assegnazione->id], session(Assegna::DA_INVIARE)); + + $link = $assegnazione->fresh()->shortPdfUrl(); + Livewire::actingAs($this->admin)->test(Assegna::class) + ->assertSee('Territorio N° 7 assegnato a Rossi Mario') + ->assertSee($link) + ->assertSee('https://wa.me/393331234567?text=', false) + ->assertSee(rawurlencode($link), false) + ->assertSee('Il link è stato inviato?'); + } + + public function test_la_scelta_registra_l_invio_e_toglie_il_riquadro(): void + { + $mario = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true, 'telefono' => '3331234567']); + $anna = Proclamatore::create(['nome' => 'Anna', 'cognome' => 'Verdi', 'attivo' => true]); + $this->assegna($mario, $this->territorioConPdf('7')); + $this->assegna($anna, $this->territorioConPdf('8')); + + [$prima, $seconda] = Assegnazione::orderBy('id')->get()->all(); + + Livewire::actingAs($this->admin)->test(Assegna::class) + ->assertSee('Territorio N° 7 assegnato') + ->assertSee('Territorio N° 8 assegnato') + ->call('esitoInvio', $prima->id, true) + ->assertDontSee('Territorio N° 7 assegnato') + ->assertSee('Territorio N° 8 assegnato') + ->call('esitoInvio', $seconda->id, false) + ->assertDontSee('Territorio N° 8 assegnato'); + + $this->assertTrue((bool) $prima->fresh()->link_sent); + $this->assertFalse((bool) $seconda->fresh()->link_sent); + $this->assertSame([], session(Assegna::DA_INVIARE)); + } + + public function test_senza_telefono_il_riquadro_lo_dice(): void + { + $anna = Proclamatore::create(['nome' => 'Anna', 'cognome' => 'Verdi', 'attivo' => true]); + $this->assegna($anna, $this->territorioConPdf()); + + Livewire::actingAs($this->admin)->test(Assegna::class) + ->assertSee('non ha un numero di telefono') + ->assertDontSee('wa.me', false); + } +} diff --git a/tests/Feature/PostaElettronicaTest.php b/tests/Feature/PostaElettronicaTest.php new file mode 100644 index 0000000..0d121f4 --- /dev/null +++ b/tests/Feature/PostaElettronicaTest.php @@ -0,0 +1,149 @@ +withoutVite(); + $this->seed(RolesAndPermissionsSeeder::class); + Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']); + + $this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']); + $this->admin->assignRole('amministratore'); + } + + private function salva(array $valori = []): void + { + $componente = Livewire::actingAs($this->admin)->test(PostaElettronica::class); + foreach ($valori + [ + 'host' => 'smtp.esempio.it', + 'porta' => '587', + 'cifratura' => ConfigurazionePosta::CIFRATURA_AUTOMATICA, + 'utente' => 'territori@esempio.it', + 'password' => 'segreta-123', + 'mittente' => 'territori@esempio.it', + 'nomeMittente' => 'Territori', + ] as $campo => $valore) { + $componente->set($campo, $valore); + } + $componente->call('salva')->assertHasNoErrors(); + } + + public function test_la_pagina_e_dell_amministratore(): void + { + $operatore = User::create(['name' => 'Op', 'email' => 'op@example.test', 'password' => 'password-di-prova']); + $operatore->assignRole('operatore'); + + $this->actingAs($operatore)->get(route('posta.edit'))->assertForbidden(); + $this->flushSession(); // due utenti nella stessa sessione: AuthenticateSession + $this->actingAs($this->admin)->get(route('posta.edit'))->assertOk(); + } + + public function test_la_configurazione_salvata_prende_il_posto_del_env(): void + { + config(['mail.default' => 'log']); + + $this->salva(); + app(ConfigurazionePosta::class)->applica(); + + $this->assertSame('smtp', config('mail.default')); + $this->assertSame('smtp.esempio.it', config('mail.mailers.smtp.host')); + $this->assertSame(587, config('mail.mailers.smtp.port')); + $this->assertSame('smtp', config('mail.mailers.smtp.scheme')); + $this->assertSame('segreta-123', config('mail.mailers.smtp.password')); + $this->assertSame('territori@esempio.it', config('mail.from.address')); + } + + public function test_ssl_usa_smtps_e_la_porta_465(): void + { + $this->salva(['cifratura' => ConfigurazionePosta::CIFRATURA_SSL, 'porta' => '']); + app(ConfigurazionePosta::class)->applica(); + + $this->assertSame('smtps', config('mail.mailers.smtp.scheme')); + $this->assertSame(465, config('mail.mailers.smtp.port')); + } + + public function test_la_password_e_cifrata_e_non_torna_al_browser(): void + { + $this->salva(); + + $grezza = DB::table('settings')->value('mail_password'); + $this->assertNotSame('segreta-123', $grezza); + $this->assertStringNotContainsString('segreta', $grezza); + + Livewire::actingAs($this->admin)->test(PostaElettronica::class) + ->assertSet('password', '') + ->assertDontSee('segreta-123'); + } + + public function test_lasciare_vuota_la_password_tiene_quella_salvata(): void + { + $this->salva(); + $this->salva(['password' => '', 'host' => 'smtp.altro.it']); + + Setting::flushCache(); + $this->assertSame('segreta-123', Setting::instance()->mail_password); + $this->assertSame('smtp.altro.it', Setting::instance()->mail_host); + } + + public function test_l_email_di_prova_parte(): void + { + Mail::fake(); + $this->salva(); + + Livewire::actingAs($this->admin)->test(PostaElettronica::class) + ->call('inviaProva') + ->assertDispatched('avviso', tipo: 'ok'); + } + + public function test_l_export_xml_non_contiene_la_posta_e_l_import_non_la_cancella(): void + { + $this->salva(); + $this->actingAs($this->admin); + + $xml = (new XmlExchange())->currentXmlBackup(); + $this->assertStringNotContainsString('smtp.esempio.it', $xml); + $this->assertStringNotContainsString('mail_host', $xml); + $this->assertStringNotContainsString('segreta', $xml); + + $esito = (new XmlExchange())->importXmlFromContent($xml); + $this->assertArrayNotHasKey('error', $esito); + + Setting::flushCache(); + $this->assertSame('smtp.esempio.it', Setting::instance()->mail_host); + $this->assertSame('segreta-123', Setting::instance()->mail_password); + } + + public function test_rimuovere_torna_al_env(): void + { + $this->salva(); + + Livewire::actingAs($this->admin)->test(PostaElettronica::class)->call('rimuovi'); + + Setting::flushCache(); + $this->assertNull(Setting::instance()->mail_host); + $this->assertFalse(app(ConfigurazionePosta::class)->configurataNellApp()); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index d9dbb16..0c84baa 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -26,4 +26,21 @@ abstract class TestCase extends BaseTestCase return $app; } + + protected function setUp(): void + { + parent::setUp(); + + // Gli utenti dei test nascono con l'email confermata: senza, ogni + // pagina li manderebbe alla conferma (middleware `verified`). I test + // sulla conferma la spengono ($confermaUtentiCreati = false). + \App\Models\User::creating(function ($utente) { + if ($this->confermaUtentiCreati && $utente->email_verified_at === null) { + $utente->email_verified_at = now(); + } + }); + } + + /** Vedi setUp(): false nei test che verificano la conferma dell'email. */ + protected bool $confermaUtentiCreati = true; }