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:
11
.env.example
11
.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}"
|
||||
|
||||
|
||||
39
HANDOFF.md
39
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 (`<x-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 <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 |
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Tre cose che vanno insieme, perché la terza ha bisogno della prima.
|
||||
*
|
||||
* 1. Il server di posta si configura dall'applicazione (Impostazioni → Posta)
|
||||
* e non più solo dal .env: servono le email di conferma degli utenti.
|
||||
* La password è cifrata con APP_KEY (cast `encrypted` sul modello).
|
||||
* 2. I proclamatori possono avere email e telefono, facoltativi. Cifrati come
|
||||
* nome e cognome: sono dati personali, e il database non deve mostrarli
|
||||
* in chiaro a chi ne legge una copia.
|
||||
* 3. Gli utenti devono confermare la loro email. Decisione dell'utente
|
||||
* (13/10/2026): anche gli account già esistenti, amministratore compreso.
|
||||
* La pagina della posta resta raggiungibile da un amministratore non
|
||||
* ancora confermato, e c'è `php artisan utenti:conferma-email` per i casi
|
||||
* in cui la posta non funziona affatto.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('settings', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
50
resources/views/auth/verifica-email.blade.php
Normal file
50
resources/views/auth/verifica-email.blade.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<x-layouts.guest title="Conferma il tuo indirizzo email">
|
||||
<div class="bg-white rounded-xl shadow-lg border border-gray-200 p-6 space-y-5">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-900">Conferma il tuo indirizzo email</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Account: <span class="font-medium text-gray-700 break-all">{{ $user->email }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="rounded-lg bg-green-50 border border-green-200 px-4 py-2.5 text-sm text-green-800">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
@if(session('error'))
|
||||
<div class="rounded-lg bg-red-50 border border-red-200 px-4 py-2.5 text-sm text-red-800">
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<p class="text-sm text-gray-700">
|
||||
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.
|
||||
</p>
|
||||
|
||||
@unless($postaFunzionante)
|
||||
<div class="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Il server di posta non è ancora configurato: l'email non può partire.
|
||||
@can('settings.manage')
|
||||
<a href="{{ route('posta.edit') }}" class="font-semibold underline">Configuralo qui</a>.
|
||||
@else
|
||||
Avvisa un amministratore.
|
||||
@endcan
|
||||
</div>
|
||||
@endunless
|
||||
|
||||
<form method="POST" action="{{ route('verification.send') }}">
|
||||
@csrf
|
||||
<x-btn variante="primary" type="submit" class="w-full">Inviami l'email di conferma</x-btn>
|
||||
</form>
|
||||
|
||||
<div class="flex items-center justify-between gap-2 text-xs text-gray-500 pt-1 border-t border-gray-100">
|
||||
<span>L'indirizzo è sbagliato? <a href="{{ route('profile') }}" class="text-indigo-700 underline">Correggilo nel profilo</a>.</span>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<x-btn variante="ghost" dimensione="sm" type="submit">Esci</x-btn>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</x-layouts.guest>
|
||||
26
resources/views/components/whatsapp.blade.php
Normal file
26
resources/views/components/whatsapp.blade.php
Normal file
@@ -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)
|
||||
<a href="{{ $url }}" target="_blank" rel="noopener"
|
||||
{{ $attributes->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>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
|
||||
@unless($compatto)
|
||||
<span>{{ $proclamatore->telefono }}</span>
|
||||
@endunless
|
||||
</a>
|
||||
@elseif(! $compatto && trim((string) $proclamatore?->telefono) !== '')
|
||||
<span {{ $attributes }}>{{ $proclamatore->telefono }}</span>
|
||||
@endif
|
||||
@@ -8,6 +8,69 @@
|
||||
<p class="text-sm text-gray-500 mt-1">Questa funzione consente un'assegnazione arbitraria, indipendente dalle tre schede della Home.</p>
|
||||
</div>
|
||||
|
||||
{{-- 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
|
||||
<section class="tm-da-inviare" wire:key="da-inviare-{{ $nuova->id }}" aria-labelledby="da-inviare-{{ $nuova->id }}">
|
||||
<h2 id="da-inviare-{{ $nuova->id }}" class="tm-da-inviare-titolo">
|
||||
Territorio N° {{ $nuova->territorio->numero }} assegnato a {{ $nuova->proclamatore->nome_completo }}
|
||||
</h2>
|
||||
|
||||
@if($link)
|
||||
<div x-data="{ copiato: false }" class="tm-da-inviare-link">
|
||||
<code x-ref="link" x-on:click="window.getSelection().selectAllChildren($el)">{{ $link }}</code>
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm"
|
||||
x-on:click="navigator.clipboard.writeText($refs.link.textContent).then(() => { copiato = true; setTimeout(() => copiato = false, 2000) })">
|
||||
<span x-show="! copiato">Copia link</span>
|
||||
<span x-show="copiato" x-cloak>Copiato</span>
|
||||
</x-btn>
|
||||
</div>
|
||||
|
||||
<div class="tm-da-inviare-azioni">
|
||||
@if($whatsapp)
|
||||
<a href="{{ $whatsapp }}" target="_blank" rel="noopener" class="tm-whatsapp tm-whatsapp-grande">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
|
||||
<span>Invia su WhatsApp</span>
|
||||
<span class="tm-whatsapp-numero">{{ $nuova->proclamatore->telefono }}</span>
|
||||
</a>
|
||||
@else
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ $nuova->proclamatore->nome_completo }} non ha un numero di telefono: copia il link e mandalo come preferisci.
|
||||
@can('settings.manage')
|
||||
<a href="{{ route('proclamatori.edit', $nuova->proclamatore) }}" class="text-indigo-700 underline">Aggiungi il numero</a>
|
||||
@endcan
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="tm-da-inviare-scelta">
|
||||
<p>Il link è stato inviato?</p>
|
||||
<div>
|
||||
<x-btn variante="primary" wire:click="esitoInvio({{ $nuova->id }}, true)">Sì, inviato</x-btn>
|
||||
<x-btn variante="secondary" wire:click="esitoInvio({{ $nuova->id }}, false)">Non ancora</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-gray-600">
|
||||
Il territorio non ha un PDF: non c'è un link da mandare. Caricalo dalla scheda del territorio.
|
||||
</p>
|
||||
<div class="tm-da-inviare-scelta">
|
||||
<div>
|
||||
<x-btn variante="secondary" wire:click="esitoInvio({{ $nuova->id }}, false)">Ho capito</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
@endforeach
|
||||
|
||||
<div x-data="{ formOpen: true }">
|
||||
|
||||
{{-- Toggle form --}}
|
||||
@@ -158,6 +221,11 @@
|
||||
<svg x-show="copied" x-cloak class="text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
</x-btn>
|
||||
|
||||
@if($a->proclamatore)
|
||||
<x-whatsapp :proclamatore="$a->proclamatore" compatto class="flex-none"
|
||||
:messaggio="'Ciao ' . $a->proclamatore->nome . ', ti è stato assegnato il territorio N° ' . $a->territorio?->numero . '. Ecco la piantina: ' . $pdfUrl" />
|
||||
@endif
|
||||
|
||||
{{-- Flag Link Inviato --}}
|
||||
{{-- Interruttore di stato, non un comando: lo stato "inviato"
|
||||
si legge dalla variante (primary quando è acceso) e
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
<p class="text-gray-500 text-sm mt-1">Accedi per continuare</p>
|
||||
</div>
|
||||
|
||||
{{-- Per esempio dopo aver aperto il link di conferma dell'email senza
|
||||
essere entrati. --}}
|
||||
@if(session('success'))
|
||||
<div class="mb-5 rounded-lg bg-green-50 border border-green-200 px-4 py-2.5 text-sm text-green-800">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('login.store') }}" class="space-y-5">
|
||||
@csrf
|
||||
<div>
|
||||
|
||||
@@ -21,6 +21,22 @@
|
||||
@error('nome') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="telefono" class="block text-sm font-medium text-gray-700">Telefono</label>
|
||||
<input wire:model="telefono" type="tel" id="telefono" autocomplete="off" placeholder="333 1234567"
|
||||
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
<p class="text-xs text-gray-500 mt-1">Facoltativo. Serve a mandare il link del territorio su WhatsApp. Senza prefisso si considera un numero italiano.</p>
|
||||
@error('telefono') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
|
||||
<input wire:model="email" type="email" id="email" autocomplete="off"
|
||||
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
<p class="text-xs text-gray-500 mt-1">Facoltativa.</p>
|
||||
@error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input wire:model="attivo" type="checkbox" id="attivo" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
<label for="attivo" class="text-sm text-gray-700">Attivo</label>
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
<td data-label="Territori assegnati" class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->assegnazioni_aperte_count ?? $proclamatore->assegnazioni()->aperte()->count() }}</td>
|
||||
<td class="px-4 py-3 text-sm text-right cell-actions">
|
||||
<div class="flex flex-wrap items-center justify-end gap-1">
|
||||
<x-whatsapp :proclamatore="$proclamatore" compatto />
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
|
||||
:href="route('proclamatori.show', $proclamatore)"
|
||||
title="Dettaglio" aria-label="Dettaglio del proclamatore">
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
Torna alla lista
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ $proclamatore->nome_completo }}</h1>
|
||||
@if($proclamatore->telefono || $proclamatore->email)
|
||||
<div class="mt-2 flex flex-wrap items-center gap-3 text-sm">
|
||||
<x-whatsapp :proclamatore="$proclamatore" />
|
||||
@if($proclamatore->email)
|
||||
<a href="mailto:{{ $proclamatore->email }}" class="text-indigo-700 hover:underline break-all">{{ $proclamatore->email }}</a>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@can('settings.manage')
|
||||
<x-btn variante="secondary" :href="route('proclamatori.edit', $proclamatore)">
|
||||
|
||||
105
resources/views/livewire/settings/posta-elettronica.blade.php
Normal file
105
resources/views/livewire/settings/posta-elettronica.blade.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Server di posta</h1>
|
||||
<p class="text-sm text-gray-500 mt-1 max-w-2xl">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if($nonConfermato)
|
||||
<div class="mb-6 max-w-2xl rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Il tuo indirizzo non è ancora confermato: il resto dell'applicazione è bloccato finché non lo fai.
|
||||
Configura qui la posta, poi <a href="{{ route('verification.notice') }}" class="font-semibold underline">richiedi l'email di conferma</a>.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mb-6 max-w-2xl rounded-lg border px-4 py-3 text-sm
|
||||
{{ $funzionante ? 'border-green-200 bg-green-50 text-green-800' : 'border-red-200 bg-red-50 text-red-800' }}">
|
||||
@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
|
||||
<strong>Nessun server di posta:</strong> le email non partono e nessun utente può confermare il proprio indirizzo.
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-2xl">
|
||||
<form wire:submit="salva" class="space-y-5">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div class="sm:col-span-2">
|
||||
<label for="host" class="block text-sm font-medium text-gray-700">Server SMTP *</label>
|
||||
<input wire:model="host" type="text" id="host" placeholder="smtp.gmail.com" autocomplete="off"
|
||||
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('host') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label for="porta" class="block text-sm font-medium text-gray-700">Porta</label>
|
||||
<input wire:model="porta" type="number" id="porta" min="1" max="65535" placeholder="587"
|
||||
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('porta') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="cifratura" class="block text-sm font-medium text-gray-700">Cifratura</label>
|
||||
<select wire:model="cifratura" id="cifratura"
|
||||
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@foreach($cifrature as $valore => $etichetta)
|
||||
<option value="{{ $valore }}">{{ $etichetta }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('cifratura') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="utente" class="block text-sm font-medium text-gray-700">Utente</label>
|
||||
<input wire:model="utente" type="text" id="utente" autocomplete="off"
|
||||
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('utente') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
|
||||
<input wire:model="password" type="password" id="password" autocomplete="new-password"
|
||||
placeholder="{{ $passwordSalvata ? 'Salvata — lascia vuoto per non cambiarla' : '' }}"
|
||||
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
<p class="text-xs text-gray-500 mt-1">Conservata cifrata. Con Gmail serve una "password per le app".</p>
|
||||
@error('password') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="mittente" class="block text-sm font-medium text-gray-700">Indirizzo del mittente *</label>
|
||||
<input wire:model="mittente" type="email" id="mittente" placeholder="territori@esempio.it"
|
||||
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('mittente') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label for="nomeMittente" class="block text-sm font-medium text-gray-700">Nome del mittente</label>
|
||||
<input wire:model="nomeMittente" type="text" id="nomeMittente" placeholder="Il nome della congregazione"
|
||||
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('nomeMittente') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 pt-2 border-t border-gray-100">
|
||||
<x-btn variante="primary" type="submit" wire:loading.attr="disabled" wire:target="salva">Salva</x-btn>
|
||||
<x-btn variante="secondary" wire:click="inviaProva" wire:loading.attr="disabled" wire:target="inviaProva"
|
||||
:disabled="! $configurataNellApp">
|
||||
<span wire:loading.remove wire:target="inviaProva">Invia email di prova</span>
|
||||
<span wire:loading wire:target="inviaProva">Invio…</span>
|
||||
</x-btn>
|
||||
@if($configurataNellApp)
|
||||
<x-btn variante="ghost" wire:click="rimuovi"
|
||||
wire:confirm="Rimuovere il server configurato qui? Varranno di nuovo le impostazioni del server (.env).">
|
||||
Rimuovi
|
||||
</x-btn>
|
||||
@endif
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">L'email di prova arriva al tuo indirizzo, {{ auth()->user()->email }}.</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -13,6 +13,14 @@
|
||||
<p class="text-xs text-gray-500 mt-2">Qui trovi import PDF territori, conversione legacy SQL, import XML ed export XML.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 bg-white rounded-xl shadow-sm border border-gray-200 p-4 max-w-lg">
|
||||
<p class="text-sm font-medium text-gray-700 mb-3">Server di posta</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-btn variante="secondary" :href="route('posta.edit')">Configura la posta</x-btn>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">Serve alle email con cui gli utenti confermano il loro indirizzo.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg">
|
||||
|
||||
<form wire:submit="save" class="space-y-5">
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Utenti</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Crea utenti e assegna un ruolo applicativo.</p>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
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.
|
||||
<a href="{{ route('posta.edit') }}" class="text-indigo-700 underline">Server di posta</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -106,7 +110,15 @@
|
||||
</td>
|
||||
@else
|
||||
<td data-label="Nome" class="px-4 py-2 text-gray-900">{{ $user->name }}</td>
|
||||
<td data-label="Email" class="px-4 py-2 text-gray-700">{{ $user->email }}</td>
|
||||
<td data-label="Email" class="px-4 py-2 text-gray-700">
|
||||
{{ $user->email }}
|
||||
@if($user->hasVerifiedEmail())
|
||||
<span class="ml-1 inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700">confermata</span>
|
||||
@else
|
||||
<span class="ml-1 inline-flex items-center rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800"
|
||||
title="L'account non funziona finché la persona non apre il link nell'email">da confermare</span>
|
||||
@endif
|
||||
</td>
|
||||
<td data-label="Ruoli" class="px-4 py-2">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@forelse($user->roles as $role)
|
||||
@@ -119,6 +131,12 @@
|
||||
<td class="px-4 py-2 cell-actions">
|
||||
<div class="flex gap-2">
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="startEdit({{ $user->id }})">Modifica</x-btn>
|
||||
@unless($user->hasVerifiedEmail())
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="reinviaConferma({{ $user->id }})"
|
||||
wire:loading.attr="disabled" wire:target="reinviaConferma({{ $user->id }})">
|
||||
Reinvia conferma
|
||||
</x-btn>
|
||||
@endunless
|
||||
{{-- wire:confirm come nel resto dell'app, al posto di un onclick
|
||||
che fermava la propagazione a mano. --}}
|
||||
<x-btn variante="danger" tono="tenue" dimensione="sm"
|
||||
|
||||
@@ -7,6 +7,8 @@ use App\Http\Controllers\Settings\XmlExchangeUploadController;
|
||||
use App\Http\Controllers\AssignmentPdfController;
|
||||
use App\Http\Controllers\ShortPdfLinkController;
|
||||
use App\Http\Controllers\TerritorioFileController;
|
||||
use App\Http\Controllers\Auth\VerificaEmailController;
|
||||
use App\Livewire\Settings\PostaElettronica;
|
||||
use App\Http\Controllers\Auth\LoginController;
|
||||
use App\Http\Controllers\Auth\PasswordChangeController;
|
||||
use App\Livewire\Home;
|
||||
@@ -68,10 +70,17 @@ Route::get('assegnazioni/pdf/{assignment}/{code}/file', [AssignmentPdfController
|
||||
| Authenticated Routes
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
Route::middleware('auth')->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 () {
|
||||
|
||||
174
tests/Feature/ConfermaEmailTest.php
Normal file
174
tests/Feature/ConfermaEmailTest.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Profile;
|
||||
use App\Livewire\Settings\UsersIndex;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Auth\Notifications\VerifyEmail;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Un account funziona solo con l'indirizzo email confermato.
|
||||
*/
|
||||
class ConfermaEmailTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected bool $confermaUtentiCreati = false;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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());
|
||||
}
|
||||
}
|
||||
210
tests/Feature/ContattiProclamatoriTest.php
Normal file
210
tests/Feature/ContattiProclamatoriTest.php
Normal file
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Assegnazioni\Assegna;
|
||||
use App\Livewire\Proclamatori\ProclamatoreCreate;
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Email e telefono dei proclamatori (facoltativi), WhatsApp, e il riquadro
|
||||
* con il link da mandare subito dopo l'assegnazione.
|
||||
*/
|
||||
class ContattiProclamatoriTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $admin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
149
tests/Feature/PostaElettronicaTest.php
Normal file
149
tests/Feature/PostaElettronicaTest.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Settings\PostaElettronica;
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Services\ConfigurazionePosta;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Il server di posta si configura dall'applicazione e prende il posto del .env.
|
||||
*/
|
||||
class PostaElettronicaTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $admin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user