++ Utenti: nuova password temporanea per il sorvegliante di gruppo

Sulla riga del sorvegliante, che prima non aveva azioni: "Nuova password
temporanea" (mostrata una volta, da cambiare al primo accesso) e "Apri il
gruppo". La logica sta in GruppiServizio::nuovaPasswordTemporanea(), usata
anche dalla pagina del gruppo; azzera anche il "ricordami".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Francesco Picone
2026-10-21 16:33:40 +02:00
parent 2fb9838a0e
commit 18fe914da5
7 changed files with 143 additions and 12 deletions

View File

@@ -111,6 +111,17 @@ lavoro nuovi, stesso master), `php-fpm -i` riporta
`session.upload_progress.enabled => Off` e `session.use_strict_mode => On`, `session.upload_progress.enabled => Off` e `session.use_strict_mode => On`,
login 200 su entrambi. login 200 su entrambi.
### Password del sorvegliante dal pannello Utenti (21 ottobre 2026, solo su dev)
Richiesta dell'utente: poter reimpostare la password del sorvegliante dal
pannello di amministrazione. Prima si poteva solo dalla pagina del gruppo; in
*Utenti* la sua riga non aveva azioni. Ora ha "Nuova password temporanea" (con
conferma) e "Apri il gruppo"; la password compare una volta sola in cima
all'elenco, con l'email, fino a "Ho annotato la password". La logica è una
sola, `GruppiServizio::nuovaPasswordTemporanea()`, usata anche da
`GruppoShow`; rifiuta gli utenti che non sono sorveglianti (per loro c'è il
modulo di modifica). Test in `GruppiServizioTest`.
### Anteprima dal numero in "Il mio gruppo" (21 ottobre 2026, solo su dev) ### Anteprima dal numero in "Il mio gruppo" (21 ottobre 2026, solo su dev)
Richiesta dell'utente: anche il sorvegliante deve poter vedere l'anteprima del Richiesta dell'utente: anche il sorvegliante deve poter vedere l'anteprima del
@@ -187,7 +198,12 @@ qui sotto **com'è oggi**, in fondo la storia.
e dal profilo l'email non si cambia. Ruolo `sorvegliante`, con il solo e dal profilo l'email non si cambia. Ruolo `sorvegliante`, con il solo
permesso `gruppo.gestisci`; non compare nella pagina *Utenti*, dove le permesso `gruppo.gestisci`; non compare nella pagina *Utenti*, dove le
utenze legate a un proclamatore non si modificano né si eliminano. utenze legate a un proclamatore non si modificano né si eliminano.
"Nuova password temporanea" dalla pagina del gruppo. "Nuova password temporanea" dalla pagina del gruppo o dalla riga del
sorvegliante nel pannello *Utenti* (stessa logica,
`GruppiServizio::nuovaPasswordTemporanea()`: password provvisoria, cambio
obbligato, "ricordami" azzerato, log `password_reimpostata`; le sessioni
aperte si chiudono perché cambia l'hash). In *Utenti* è l'unica azione su
quell'utenza, con "Apri il gruppo".
- **Cosa vede**: `LimitaSorvegliante` (gruppo `web`) lascia a chi ha **solo** - **Cosa vede**: `LimitaSorvegliante` (gruppo `web`) lascia a chi ha **solo**
quel ruolo `mio-gruppo*`, `assegnazioni.rientra` (solo per il suo gruppo: quel ruolo `mio-gruppo*`, `assegnazioni.rientra` (solo per il suo gruppo:
`Rientra::autorizza`), profilo, cambio password e le azioni Livewire — `Rientra::autorizza`), profilo, cambio password e le azioni Livewire —

View File

@@ -759,7 +759,10 @@ docker compose --profile dev up -d --build
"Password temporanea", non "Ripristino da backup"). L'email non si "Password temporanea", non "Ripristino da backup"). L'email non si
chiede, non riceve posta e non va confermata. Due gruppi non possono avere chiede, non riceve posta e non va confermata. Due gruppi non possono avere
lo stesso nome. Se perde la password, l'amministratore ne genera un'altra lo stesso nome. Se perde la password, l'amministratore ne genera un'altra
dalla pagina del gruppo temporanea dalla pagina del gruppo o dal pannello *Utenti* ("Nuova password
temporanea" sulla riga del sorvegliante): si vede una volta sola, va
cambiata al primo accesso, e le sessioni aperte con quella vecchia si
chiudono
- I territori del pacchetto si aggiungono in blocco tra quelli in reparto, - I territori del pacchetto si aggiungono in blocco tra quelli in reparto,
**in ordine di priorità** come "Da assegnare" nella Home (prima i **in ordine di priorità** come "Da assegnare" nella Home (prima i
prioritari, poi quelli fermi da più tempo), con ricerca, filtri per zona, prioritari, poi quelli fermi da più tempo), con ricerca, filtri per zona,
@@ -858,7 +861,8 @@ L'amministratore ha un bypass completo (`Gate::before` in `AppServiceProvider`).
Il menu laterale mostra solo le voci per cui l'utente ha i permessi. Il menu laterale mostra solo le voci per cui l'utente ha i permessi.
Il ruolo **sorvegliante** non si assegna dalla pagina *Utenti*: nasce creando Il ruolo **sorvegliante** non si assegna dalla pagina *Utenti*: nasce creando
un gruppo di servizio e sparisce sciogliendolo. Chi ha solo quel ruolo è un gruppo di servizio e sparisce sciogliendolo. Della sua utenza, in *Utenti*,
si può solo generare una nuova password temporanea (e aprire il gruppo). Chi ha solo quel ruolo è
confinato da `LimitaSorvegliante` alla pagina del proprio gruppo, al rientro confinato da `LimitaSorvegliante` alla pagina del proprio gruppo, al rientro
dei territori del gruppo e al profilo; le sue azioni ricontrollano che dei territori del gruppo e al profilo; le sue azioni ricontrollano che
territorio e assegnazione siano del suo gruppo. territorio e assegnazione siano del suo gruppo.

View File

@@ -157,11 +157,7 @@ class GruppoShow extends Component
return; return;
} }
$password = Str::password(12, symbols: false); $this->passwordTemporanea = app(GruppiServizio::class)->nuovaPasswordTemporanea($utente);
$utente->forceFill(['password' => $password, 'password_change_required' => true, 'password_temporanea' => true])->save();
activity()->causedBy(auth()->user())->performedOn($utente)->log('password_reimpostata');
$this->passwordTemporanea = $password;
} }
/** /**

View File

@@ -3,8 +3,11 @@
namespace App\Livewire\Settings; namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa; use App\Livewire\Concerns\Avvisa;
use App\Models\GruppoServizio;
use App\Models\User; use App\Models\User;
use App\Services\GruppiServizio;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use InvalidArgumentException;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Component; use Livewire\Component;
use Spatie\Permission\Models\Role; use Spatie\Permission\Models\Role;
@@ -22,6 +25,17 @@ class UsersIndex extends Component
#[Locked] #[Locked]
public array $availableRoles = []; public array $availableRoles = [];
/**
* Nuova password temporanea di un sorvegliante di gruppo, mostrata una
* volta sola (come nella pagina del gruppo). Locked: le scrive solo
* nuovaPasswordSorvegliante().
*/
#[Locked]
public ?int $passwordPerUtenteId = null;
#[Locked]
public ?string $passwordTemporanea = null;
public ?int $editingUserId = null; public ?int $editingUserId = null;
public string $editName = ''; public string $editName = '';
public string $editEmail = ''; public string $editEmail = '';
@@ -238,10 +252,39 @@ class UsersIndex extends Component
$this->avvisa('Utente cancellato. I log sono stati preservati.'); $this->avvisa('Utente cancellato. I log sono stati preservati.');
} }
/**
* Il sorvegliante ha perso la password: una temporanea nuova, da cambiare
* al primo accesso. È l'unica cosa che di quell'utenza si fa da qui; il
* resto passa dal gruppo (gestitaDaiGruppi).
*/
public function nuovaPasswordSorvegliante(int $userId): void
{
$this->authorize('settings.manage');
$user = User::query()->findOrFail($userId);
try {
$this->passwordTemporanea = app(GruppiServizio::class)->nuovaPasswordTemporanea($user);
} catch (InvalidArgumentException $e) {
$this->avvisaErrore($e->getMessage());
return;
}
$this->passwordPerUtenteId = $user->id;
}
public function chiudiPassword(): void
{
$this->reset(['passwordPerUtenteId', 'passwordTemporanea']);
}
public function render() public function render()
{ {
return view('livewire.settings.users-index', [ return view('livewire.settings.users-index', [
'users' => User::query()->with('roles')->orderBy('name')->get(), 'users' => User::query()->with('roles')->orderBy('name')->get(),
// Per ogni sorvegliante il suo gruppo, per il collegamento.
'gruppiDeiSorveglianti' => GruppoServizio::pluck('id', 'sorvegliante_id'),
'utentePassword' => $this->passwordPerUtenteId ? User::find($this->passwordPerUtenteId) : null,
]); ]);
} }
} }

View File

@@ -80,6 +80,32 @@ class GruppiServizio
return [$gruppo, $password]; return [$gruppo, $password];
} }
/**
* Una password temporanea nuova per il sorvegliante che l'ha persa: da
* cambiare al primo accesso ("Password temporanea"). Le sessioni aperte
* con quella vecchia si chiudono alla richiesta successiva
* (AuthenticateSession: è cambiato l'hash), il "ricordami" si azzera.
* Restituisce la password, da mostrare una volta sola.
*/
public function nuovaPasswordTemporanea(User $utente): string
{
if (! $utente->proclamatore_id || ! $utente->isSorvegliante()) {
throw new InvalidArgumentException("{$utente->name} non è il sorvegliante di un gruppo.");
}
$password = Str::password(12, symbols: false);
$utente->forceFill([
'password' => $password,
'password_change_required' => true,
'password_temporanea' => true,
'remember_token' => null,
])->save();
activity()->causedBy(auth()->user())->performedOn($utente)->log('password_reimpostata');
return $password;
}
/** /**
* L'email fittizia del sorvegliante, con cui entra: iniziale del nome e * L'email fittizia del sorvegliante, con cui entra: iniziale del nome e
* cognome, minuscoli, senza accenti, spazi né apostrofi, sul dominio * cognome, minuscoli, senza accenti, spazi né apostrofi, sul dominio

View File

@@ -57,6 +57,19 @@
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Utenti esistenti</h2> <h2 class="text-lg font-semibold text-gray-900 mb-4">Utenti esistenti</h2>
@if($passwordTemporanea && $utentePassword)
<div class="mb-4 rounded-xl border-2 border-green-200 bg-green-50 p-4 text-sm text-green-900" role="status">
<p>Nuova password temporanea per <strong>{{ $utentePassword->name }}</strong>, da cambiare al primo accesso. Si vede <strong>solo ora</strong>:</p>
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
<dt class="text-green-700">Email</dt><dd><code class="select-all">{{ $utentePassword->email }}</code></dd>
<dt class="text-green-700">Password</dt><dd><code class="select-all text-base">{{ $passwordTemporanea }}</code></dd>
</dl>
<div class="mt-3">
<x-btn variante="ghost" dimensione="sm" wire:click="chiudiPassword">Ho annotato la password</x-btn>
</div>
</div>
@endif
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards"> <table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
@@ -130,10 +143,16 @@
</td> </td>
<td class="px-4 py-2 cell-actions"> <td class="px-4 py-2 cell-actions">
@if($user->proclamatore_id) @if($user->proclamatore_id)
{{-- Utenza di un sorvegliante di gruppo: si gestisce dal gruppo. --}} {{-- Utenza di un sorvegliante di gruppo: si gestisce dal gruppo;
<span class="text-xs text-gray-500"> da qui solo la password, se l'ha persa. --}}
Sorvegliante: si gestisce da Gruppi di servizio <div class="flex flex-wrap items-center gap-2">
</span> <x-btn variante="secondary" tono="tenue" dimensione="sm"
wire:click="nuovaPasswordSorvegliante({{ $user->id }})"
wire:confirm="Generare una nuova password temporanea per {{ $user->name }}? Quella attuale smette di funzionare.">Nuova password temporanea</x-btn>
@if(isset($gruppiDeiSorveglianti[$user->proclamatore_id]))
<x-btn data-ritorno variante="ghost" dimensione="sm" :href="route('gruppi.show', $gruppiDeiSorveglianti[$user->proclamatore_id])">Apri il gruppo</x-btn>
@endif
</div>
@else @else
<div class="flex gap-2"> <div class="flex gap-2">
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="startEdit({{ $user->id }})">Modifica</x-btn> <x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="startEdit({{ $user->id }})">Modifica</x-btn>

View File

@@ -155,6 +155,33 @@ class GruppiServizioTest extends TestCase
$this->assertNotNull($sorvegliante->fresh()); $this->assertNotNull($sorvegliante->fresh());
} }
public function test_dal_pannello_utenti_si_da_al_sorvegliante_una_nuova_password_temporanea(): void
{
['sorvegliante' => $s, 'gruppo' => $gruppo] = $this->gruppo();
$pagina = Livewire::test(UsersIndex::class)
->assertSee('Nuova password temporanea')
->assertSee(route('gruppi.show', $gruppo), false)
->call('nuovaPasswordSorvegliante', $s->id);
// Si vede una volta, ed è quella vera: temporanea, da cambiare.
$password = $pagina->get('passwordTemporanea');
$this->assertNotEmpty($password);
$pagina->assertSee($password)->assertSee('mrossi@termanager.it');
$s->refresh();
$this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $s->password));
$this->assertTrue($s->password_change_required);
$this->assertTrue($s->password_temporanea);
$this->assertSame(1, \Spatie\Activitylog\Models\Activity::where('description', 'password_reimpostata')->count());
$pagina->call('chiudiPassword')->assertDontSee($password);
// Solo per i sorveglianti: gli altri utenti hanno il loro modulo.
$pagina->call('nuovaPasswordSorvegliante', $this->admin->id)
->assertDispatched('avviso', fn ($nome, $parametri) => $parametri['tipo'] === 'errore');
$this->assertTrue(\Illuminate\Support\Facades\Hash::check('password-di-prova', $this->admin->fresh()->password));
}
// ─── Pacchetto ────────────────────────────────────────────── // ─── Pacchetto ──────────────────────────────────────────────
public function test_il_pacchetto_non_e_un_assegnazione_e_non_e_in_reparto(): void public function test_il_pacchetto_non_e_un_assegnazione_e_non_e_in_reparto(): void