Posta configurabile dall'app, contatti dei proclamatori, conferma email
- Server di posta in Impostazioni (password cifrata, email di prova): se compilato prende il posto del .env; non si esporta e l'import XML non lo cancella. MAIL_SCHEME=tls nel .env.example non era valido per Laravel 11. - Proclamatori con email e telefono facoltativi, cifrati, nell'XML e cancellati dall'anonimizzazione; il numero apre WhatsApp. - Dopo l'assegnazione un riquadro in evidenza con il link del PDF, "Invia su WhatsApp" con il messaggio pronto e la scelta "inviato / non ancora", che resta finché non si sceglie. - Conferma dell'email obbligatoria per tutti gli utenti, anche esistenti: email in italiano, link valido senza login, nuova conferma a ogni cambio di indirizzo, "Reinvia conferma" in Utenti. Senza conferma restano raggiungibili profilo, cambio password e, per l'admin, il server di posta; comando utenti:conferma-email per l'emergenza. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
44
app/Console/Commands/ConfermaEmailUtente.php
Normal file
44
app/Console/Commands/ConfermaEmailUtente.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Conferma a mano l'email di un utente, dalla riga di comando.
|
||||
*
|
||||
* Per l'emergenza: la posta non funziona e nessun amministratore confermato
|
||||
* può sistemarla (per esempio subito dopo l'aggiornamento che ha reso
|
||||
* obbligatoria la conferma). Chi ha accesso al server è già fidato.
|
||||
*/
|
||||
class ConfermaEmailUtente extends Command
|
||||
{
|
||||
protected $signature = 'utenti:conferma-email {email : Indirizzo dell\'utente da confermare}';
|
||||
|
||||
protected $description = 'Segna come confermata l\'email di un utente (emergenza: posta non funzionante)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$user = User::where('email', $this->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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
77
app/Http/Controllers/Auth/VerificaEmailController.php
Normal file
77
app/Http/Controllers/Auth/VerificaEmailController.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\ConfigurazionePosta;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Conferma dell'indirizzo email degli utenti.
|
||||
*
|
||||
* Finché l'indirizzo non è confermato l'account non funziona: il middleware
|
||||
* `verified` porta qui. Il link della mail funziona anche senza essere
|
||||
* entrati: lo si apre spesso dal telefono, dove la sessione non c'è, e la
|
||||
* firma dell'indirizzo (con scadenza) basta a garantire che venga dalla mail.
|
||||
*/
|
||||
class VerificaEmailController extends Controller
|
||||
{
|
||||
public function avviso(Request $request, ConfigurazionePosta $posta)
|
||||
{
|
||||
if ($request->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.');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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.');
|
||||
|
||||
|
||||
132
app/Livewire/Settings/PostaElettronica.php
Normal file
132
app/Livewire/Settings/PostaElettronica.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use App\Models\Setting;
|
||||
use App\Services\ConfigurazionePosta;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Server di posta (solo amministratore).
|
||||
*
|
||||
* Serve alle email di conferma degli utenti. Raggiungibile anche da un
|
||||
* amministratore che non ha ancora confermato la sua email (vedi
|
||||
* routes/web.php): se la posta non funziona, è l'unica pagina da cui
|
||||
* sistemarla senza passare dal server.
|
||||
*/
|
||||
class PostaElettronica extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
|
||||
public string $host = '';
|
||||
|
||||
public string $porta = '';
|
||||
|
||||
public string $cifratura = ConfigurazionePosta::CIFRATURA_AUTOMATICA;
|
||||
|
||||
public string $utente = '';
|
||||
|
||||
/** Vuota = si tiene quella salvata. Non torna mai al browser. */
|
||||
public string $password = '';
|
||||
|
||||
public string $mittente = '';
|
||||
|
||||
public string $nomeMittente = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$impostazioni = Setting::instance();
|
||||
|
||||
$this->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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
121
app/Services/ConfigurazionePosta.php
Normal file
121
app/Services/ConfigurazionePosta.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Mail\MailManager;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Il server di posta configurato dall'applicazione (Impostazioni → Posta).
|
||||
*
|
||||
* Se in Impostazioni c'è un server, vale quello; altrimenti resta quello del
|
||||
* .env, come prima. La configurazione si applica quando Laravel crea il
|
||||
* sistema di posta (AppServiceProvider) e prima di ogni job della coda,
|
||||
* perché il worker vive a lungo e avrebbe tenuto quella del primo invio.
|
||||
*/
|
||||
class ConfigurazionePosta
|
||||
{
|
||||
public const CIFRATURA_AUTOMATICA = 'auto';
|
||||
|
||||
public const CIFRATURA_SSL = 'ssl';
|
||||
|
||||
public const CIFRATURA_NESSUNA = 'nessuna';
|
||||
|
||||
public const CIFRATURE = [
|
||||
self::CIFRATURA_AUTOMATICA => '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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user