diff --git a/app/Jobs/ImportTerritoryPdfFolder.php b/app/Jobs/ImportTerritoryPdfFolder.php index 9fe6547..605ee59 100644 --- a/app/Jobs/ImportTerritoryPdfFolder.php +++ b/app/Jobs/ImportTerritoryPdfFolder.php @@ -22,7 +22,11 @@ class ImportTerritoryPdfFolder implements ShouldQueue use SerializesModels; public int $tries = 1; - public int $timeout = 0; + + // A bounded timeout, not 0. With no limit a single PDF that hangs the + // renderer blocks the worker forever and the import state never leaves + // "running", so the UI polls a job that will never finish. + public int $timeout = 900; public function __construct( public string $importId, @@ -115,7 +119,14 @@ class ImportTerritoryPdfFolder implements ShouldQueue $thumbnailService->delete($territorio->thumbnail_path); } - $storedFilename = Str::slug('territorio-' . $territorio->numero) . '.pdf'; + // The territory id is part of the filename because the slug + // alone is not unique: "1 A" and "1-A" are distinct + // territory numbers that both slug to "territorio-1-a". + // Without it the second import overwrites the first's PDF + // and both rows point at one file — then removing the PDF + // from either territory breaks the other. + $storedFilename = Str::slug('territorio-' . $territorio->numero) + . '-' . $territorio->id . '.pdf'; $publicPath = 'territori-pdf/' . $storedFilename; Storage::disk('public')->put($publicPath, file_get_contents($sourcePath)); $thumbnailPath = $thumbnailService->generate($publicPath); @@ -166,6 +177,26 @@ class ImportTerritoryPdfFolder implements ShouldQueue } } + /** + * Runs when the job exceeds $timeout or dies outside handle()'s try block. + * + * Without this the import state is stranded in "running" — the UI polls it + * until the 24h cache entry expires — and the uploaded temp directory is + * never reclaimed, since handle()'s finally never executes. + */ + public function failed(?\Throwable $exception): void + { + $stateService = app(TerritorioPdfImportState::class); + + $stateService->appendLog( + $this->importId, + '[ERR] Job terminato senza completare: ' . ($exception?->getMessage() ?? 'timeout') + ); + $stateService->markFailed($this->importId); + + Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $this->importId); + } + protected function resolveTerritoryFromFilename(string $filename, array $territoriMap): ?array { if ($territoriMap === []) { diff --git a/app/Livewire/Assegnazioni/Rientra.php b/app/Livewire/Assegnazioni/Rientra.php index 2211d50..430d6d3 100644 --- a/app/Livewire/Assegnazioni/Rientra.php +++ b/app/Livewire/Assegnazioni/Rientra.php @@ -2,6 +2,7 @@ namespace App\Livewire\Assegnazioni; +use Livewire\Attributes\Locked; use Livewire\Component; use App\Models\Assegnazione; use App\Models\Campagna; @@ -11,7 +12,14 @@ class Rientra extends Component public Assegnazione $assegnazione; public string $returned_at = ''; public bool $showCampaignPrompt = false; + + // Derived server-side by checkCampaign() and never bound in the view. + // Without #[Locked] the client can inject an arbitrary campaign id into + // the update payload and have the assignment counted toward a campaign it + // has no date overlap with (or hit an FK violation with a bogus id). + #[Locked] public ?int $campagna_id = null; + public bool $counted_in_campaign = false; public function mount(Assegnazione $assegnazione) @@ -62,6 +70,12 @@ class Rientra extends Component { $this->validate(); + // mount() rejects an already-returned assignment, but the component + // state persists across requests — two open tabs would both submit and + // the second would overwrite returned_at/returned_by and log a + // duplicate activity entry. Re-check against the current row. + abort_if($this->assegnazione->fresh()?->returned_at !== null, 409, 'Assegnazione già rientrata.'); + $returnDate = \Carbon\Carbon::parse($this->returned_at); $this->assegnazione->update([ diff --git a/app/Livewire/Proclamatori/ProclamatoreIndex.php b/app/Livewire/Proclamatori/ProclamatoreIndex.php index 0cba542..27637ee 100644 --- a/app/Livewire/Proclamatori/ProclamatoreIndex.php +++ b/app/Livewire/Proclamatori/ProclamatoreIndex.php @@ -68,7 +68,12 @@ class ProclamatoreIndex extends Component public function render() { - $query = Proclamatore::query(); + // withCount rather than letting the view call + // ->assegnazioni()->aperte()->count() per row, which issued one + // COUNT(*) per displayed proclamatore on every render. + $query = Proclamatore::query()->withCount([ + 'assegnazioni as assegnazioni_aperte_count' => fn ($q) => $q->whereNull('returned_at'), + ]); if ($this->search !== '') { // In-memory filter because nome/cognome are encrypted diff --git a/app/Livewire/Registro.php b/app/Livewire/Registro.php index 40e5b4e..9245636 100644 --- a/app/Livewire/Registro.php +++ b/app/Livewire/Registro.php @@ -54,8 +54,26 @@ class Registro extends Component $this->resetPage(); } + /** + * Columns that may be sorted on. + * + * $sortField reaches orderBy() directly, and it is a public Livewire + * property, so the client controls it. Laravel's grammar prevents SQL + * injection, but an unknown column still raises "Column not found" and + * returns a 500 — trivially triggerable from the browser console. + */ + private const SORTABLE_FIELDS = [ + 'territorio_numero', + 'assigned_at', + 'returned_at', + ]; + public function sortBy(string $field) { + if (! in_array($field, self::SORTABLE_FIELDS, true)) { + return; + } + if ($this->sortField === $field) { $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; } else { @@ -179,13 +197,19 @@ class Registro extends Component $query->whereHas('territorio', fn($q) => $q->where('tipologia_id', $this->filtroTipologia)); } - if ($this->sortField === 'territorio_numero') { - $dir = $this->sortDirection === 'asc' ? 'ASC' : 'DESC'; + // Re-check here too: sortBy() guards the action, but the property can + // also be set directly in a Livewire update payload. + $sortField = in_array($this->sortField, self::SORTABLE_FIELDS, true) + ? $this->sortField + : 'territorio_numero'; + $dir = $this->sortDirection === 'asc' ? 'ASC' : 'DESC'; + + if ($sortField === 'territorio_numero') { $query->orderByRaw( "CAST((SELECT numero FROM territori WHERE territori.id = assegnazioni.territorio_id) AS UNSIGNED) $dir" ); } else { - $query->orderBy($this->sortField, $this->sortDirection); + $query->orderBy($sortField, $dir); } // In-memory search for encrypted proclamatore fields / territorio numero diff --git a/app/Livewire/Settings/XmlExchange.php b/app/Livewire/Settings/XmlExchange.php index effcff7..9c2d3a2 100644 --- a/app/Livewire/Settings/XmlExchange.php +++ b/app/Livewire/Settings/XmlExchange.php @@ -72,6 +72,11 @@ class XmlExchange extends Component $this->pdfFolder = []; + $this->dispatchPdfImport( + $importId, + $storedFiles, + 'Cartella ricevuta: ' . count($storedFiles) . ' PDF in coda.' + ); } public function refreshPdfImportStatus(): void @@ -197,6 +202,12 @@ class XmlExchange extends Component AnnoTeocratico::query()->delete(); Setting::query()->delete(); + // The mass delete above fires no model events, so Setting's + // memoized instance still points at the deleted row. Without this + // the update() below would target a nonexistent id, match zero + // rows, and silently leave the app with no settings at all. + Setting::flushCache(); + $settingsNode = $xml->settings; if ($settingsNode) { Setting::instance()->update([ diff --git a/app/Livewire/Territori/TerritorioIndex.php b/app/Livewire/Territori/TerritorioIndex.php index be892a6..d984eb4 100644 --- a/app/Livewire/Territori/TerritorioIndex.php +++ b/app/Livewire/Territori/TerritorioIndex.php @@ -123,7 +123,16 @@ class TerritorioIndex extends Component public function render() { - $query = Territorio::with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore']); + // ultimaAssegnazione is eager-loaded because getGiorniGiacenzaAttribute() + // and is_prioritario read it for every row — and this query fetches the + // whole table (pagination happens in memory below), so lazy-loading it + // meant one extra query per territory on every render. + $query = Territorio::with([ + 'zona', + 'tipologia', + 'assegnazioneCorrente.proclamatore', + 'ultimaAssegnazione', + ]); if ($this->search) { $search = $this->search; diff --git a/app/Models/Setting.php b/app/Models/Setting.php index f958ea6..60eab2c 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -47,6 +47,19 @@ class Setting extends Model }); } + /** + * Drop the memoized instance. + * + * Mass operations (Setting::query()->delete(), truncate, raw SQL) do not + * fire model events, so the `saved`/`deleted` hooks above can't catch + * them and the cache would keep serving a row that no longer exists. + * Call this explicitly after any such operation. + */ + public static function flushCache(): void + { + static::$cachedInstance = null; + } + /** * Get the singleton settings instance (first row). */ diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d9afa77..dacebbf 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,8 +4,9 @@ namespace App\Providers; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Facades\Gate; -use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\URL; +use Livewire\Livewire; +use RuntimeException; class AppServiceProvider extends ServiceProvider { @@ -16,16 +17,57 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { - // Force HTTPS when behind a reverse proxy - if (str_starts_with(config('app.url'), 'https://')) { - URL::forceScheme('https'); + // Pin generated URLs to APP_URL. + // + // Behind a trusted proxy, Symfony derives the port from + // X-Forwarded-Port and falls back to the scheme's default when that + // header is absent — which silently drops a non-standard port from + // every generated link. Anchoring the root to APP_URL makes the + // public address explicit instead of reconstructed per request, and + // keeps PDF/e-mail links correct regardless of proxy behaviour. + $appUrl = (string) config('app.url'); + + if ($appUrl !== '') { + URL::forceRootUrl($appUrl); + + if (str_starts_with($appUrl, 'https://')) { + URL::forceScheme('https'); + } } - // Auto-generate APP_KEY if missing - if (empty(config('app.key'))) { - Artisan::call('key:generate', ['--force' => true]); + // NOTE: APP_KEY is deliberately NOT auto-generated here. + // + // This provider boots on every request. Generating a key at runtime + // meant that if the key ever failed to persist, each request could + // mint a *different* one — and since proclamatori names are encrypted + // at column level with APP_KEY, every row written under a superseded + // key becomes permanently unreadable. + // + // The key is now resolved once at container start (docker/php/ + // entrypoint.sh) and persisted to the storage volume. Fail loudly + // instead of silently rotating. + // Only enforced when serving HTTP. Build-time tooling (package:discover, + // config:cache) legitimately runs before the key exists, and failing + // there would break the image build rather than surface a real problem. + if (empty(config('app.key')) && ! $this->app->runningInConsole()) { + throw new RuntimeException( + 'APP_KEY is not set. Set it in the environment before starting the application. ' + .'Generate one with: php artisan key:generate --show' + ); } + // Livewire only re-applies a fixed allow-list of middleware to + // /livewire/update requests. Spatie's permission middleware is not on + // that list, so a component whose route is guarded by + // `permission:territori.manage` would keep accepting mutating actions + // from a user whose permission was revoked after page load, until they + // reload. Registering them as persistent closes that window. + Livewire::addPersistentMiddleware([ + \Spatie\Permission\Middleware\PermissionMiddleware::class, + \Spatie\Permission\Middleware\RoleMiddleware::class, + \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class, + ]); + Gate::before(function ($user, $ability) { return $user->hasRole('amministratore') ? true : null; }); diff --git a/app/Services/TerritorioPdfImportDispatcher.php b/app/Services/TerritorioPdfImportDispatcher.php index 3813822..a6cb74d 100644 --- a/app/Services/TerritorioPdfImportDispatcher.php +++ b/app/Services/TerritorioPdfImportDispatcher.php @@ -4,12 +4,19 @@ namespace App\Services; use App\Jobs\ImportTerritoryPdfFolder; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use RuntimeException; use ZipArchive; class TerritorioPdfImportDispatcher { + /** Per-entry uncompressed ceiling, matching the 10 MB Livewire upload rule. */ + private const MAX_ENTRY_BYTES = 10 * 1024 * 1024; + + /** Caps the job payload — every entry is serialized into one queue message. */ + private const MAX_ENTRIES = 2000; + public function __construct( protected TerritorioPdfImportState $stateService, ) { @@ -56,6 +63,25 @@ class TerritorioPdfImportDispatcher continue; } + if ($entryIndex >= self::MAX_ENTRIES) { + throw new RuntimeException( + 'Lo ZIP contiene più di ' . self::MAX_ENTRIES . ' PDF. Suddividilo in archivi più piccoli.' + ); + } + + // Check the *uncompressed* size before reading. getFromIndex() + // inflates the entry entirely into memory, so a small zip with + // one hugely compressible entry would otherwise exhaust PHP's + // memory limit and abort the request mid-extraction. + $uncompressedSize = $zip->statIndex($index)['size'] ?? 0; + if ($uncompressedSize > self::MAX_ENTRY_BYTES) { + throw new RuntimeException(sprintf( + 'Il file "%s" nello ZIP supera %d MB.', + basename($entryName), + self::MAX_ENTRY_BYTES / 1048576 + )); + } + $content = $zip->getFromIndex($index); if ($content === false) { continue; @@ -74,6 +100,12 @@ class TerritorioPdfImportDispatcher $entryIndex++; } + } catch (\Throwable $exception) { + // Nothing was queued, so the job's own cleanup will never run — + // drop the partially extracted files rather than orphaning them. + Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId); + + throw $exception; } finally { $zip->close(); } diff --git a/app/Services/TerritorioThumbnailService.php b/app/Services/TerritorioThumbnailService.php index 57acaa3..3d84a0c 100644 --- a/app/Services/TerritorioThumbnailService.php +++ b/app/Services/TerritorioThumbnailService.php @@ -2,10 +2,20 @@ namespace App\Services; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; +use Symfony\Component\Process\Exception\ProcessTimedOutException; +use Symfony\Component\Process\Process; class TerritorioThumbnailService { + /** + * Wall-clock limit for pdftoppm. A malformed PDF can make it spin + * indefinitely; unbounded, that wedges the queue worker for good. + */ + private const RENDER_TIMEOUT_SECONDS = 60; + + /** * Generate a PNG thumbnail of the first page of a PDF stored on the public disk. * @@ -22,11 +32,26 @@ class TerritorioThumbnailService $tempPrefix = sys_get_temp_dir() . '/terr_thumb_' . uniqid(); - exec( - 'pdftoppm -r 72 -png -l 1 ' . escapeshellarg($pdfAbsPath) . ' ' . escapeshellarg($tempPrefix), - $output, - $exitCode - ); + $process = new Process([ + 'pdftoppm', '-r', '72', '-png', '-l', '1', $pdfAbsPath, $tempPrefix, + ]); + $process->setTimeout(self::RENDER_TIMEOUT_SECONDS); + + try { + $process->run(); + } catch (ProcessTimedOutException) { + Log::warning('pdftoppm timed out generating a territory thumbnail.', [ + 'pdf' => $pdfStoragePath, + 'timeout' => self::RENDER_TIMEOUT_SECONDS, + ]); + + // Clean up whatever partial pages it managed to write. + foreach (glob($tempPrefix . '*.png') ?: [] as $partial) { + @unlink($partial); + } + + return null; + } // pdftoppm may produce -1.png, -01.png or even -001.png depending on page count $generated = null; diff --git a/bootstrap/app.php b/bootstrap/app.php index dda1677..79082d4 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,14 +12,30 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware) { + // X_FORWARDED_HOST is deliberately excluded. Trusting it lets a client + // set an arbitrary Host and have route()/url() emit links pointing at + // an attacker's domain — including the public assignment PDF links, + // which carry a valid access code. $middleware->trustProxies( at: '*', headers: Request::HEADER_X_FORWARDED_FOR - | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO ); + // Second line of defence: only serve requests whose Host matches + // APP_URL. Relaxed outside production so local access by IP works. + $middleware->trustHosts(at: function () { + $host = parse_url((string) config('app.url'), PHP_URL_HOST); + + // localhost/127.0.0.1 are kept for container-internal probes: the + // Docker health check and Dokploy's own checks address the + // container directly, not through the public domain. They are not + // an external hole — Traefik only forwards requests whose Host + // matches a configured domain. + return array_values(array_filter([$host, 'localhost', '127.0.0.1'])); + }, subdomains: false); + $middleware->alias([ 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, 'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, diff --git a/config/session.php b/config/session.php index fc16ea8..e3d5aa2 100644 --- a/config/session.php +++ b/config/session.php @@ -15,7 +15,10 @@ return [ 'cookie' => env('SESSION_COOKIE', 'termanager2_session'), 'path' => env('SESSION_PATH', '/'), 'domain' => env('SESSION_DOMAIN'), - 'secure' => env('SESSION_SECURE_COOKIE'), + // Defaults to true outside local: the app is served over HTTPS via the + // reverse proxy, and without the Secure flag the session cookie would be + // transmitted in cleartext on any plain-HTTP request to the same host. + 'secure' => env('SESSION_SECURE_COOKIE', env('APP_ENV') !== 'local'), 'http_only' => env('SESSION_HTTP_ONLY', true), 'same_site' => env('SESSION_SAME_SITE', 'lax'), 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), diff --git a/database/migrations/2026_04_07_175504_add_thumbnail_path_to_territori_table.php b/database/migrations/2026_04_07_175504_add_thumbnail_path_to_territori_table.php index ee5f8f8..adc0e5a 100644 --- a/database/migrations/2026_04_07_175504_add_thumbnail_path_to_territori_table.php +++ b/database/migrations/2026_04_07_175504_add_thumbnail_path_to_territori_table.php @@ -7,20 +7,28 @@ use Illuminate\Support\Facades\Schema; return new class extends Migration { /** - * Run the migrations. + * Guarded on both sides because two follow-up migrations used to re-add + * this same column. They have been removed, but a database that already + * ran them may have the column in either state, and an unguarded + * add/drop would abort the whole migration run. */ public function up(): void { + if (Schema::hasColumn('territori', 'thumbnail_path')) { + return; + } + Schema::table('territori', function (Blueprint $table) { $table->string('thumbnail_path')->nullable()->after('pdf_path'); }); } - /** - * Reverse the migrations. - */ public function down(): void { + if (! Schema::hasColumn('territori', 'thumbnail_path')) { + return; + } + Schema::table('territori', function (Blueprint $table) { $table->dropColumn('thumbnail_path'); }); diff --git a/database/migrations/2026_04_07_184636_ensure_thumbnail_path_on_territori_table.php b/database/migrations/2026_04_07_184636_ensure_thumbnail_path_on_territori_table.php deleted file mode 100644 index b23d1a1..0000000 --- a/database/migrations/2026_04_07_184636_ensure_thumbnail_path_on_territori_table.php +++ /dev/null @@ -1,32 +0,0 @@ -string('thumbnail_path')->nullable()->after('pdf_path'); - }); - } - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - if (Schema::hasColumn('territori', 'thumbnail_path')) { - Schema::table('territori', function (Blueprint $table) { - $table->dropColumn('thumbnail_path'); - }); - } - } -}; diff --git a/database/migrations/2026_04_07_184758_force_add_missing_thumbnail_path_to_territori_table.php b/database/migrations/2026_04_07_184758_force_add_missing_thumbnail_path_to_territori_table.php deleted file mode 100644 index b23d1a1..0000000 --- a/database/migrations/2026_04_07_184758_force_add_missing_thumbnail_path_to_territori_table.php +++ /dev/null @@ -1,32 +0,0 @@ -string('thumbnail_path')->nullable()->after('pdf_path'); - }); - } - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - if (Schema::hasColumn('territori', 'thumbnail_path')) { - Schema::table('territori', function (Blueprint $table) { - $table->dropColumn('thumbnail_path'); - }); - } - } -}; diff --git a/database/migrations/2026_07_18_000001_fix_link_ttl_default_and_add_indexes.php b/database/migrations/2026_07_18_000001_fix_link_ttl_default_and_add_indexes.php new file mode 100644 index 0000000..fb8abcd --- /dev/null +++ b/database/migrations/2026_07_18_000001_fix_link_ttl_default_and_add_indexes.php @@ -0,0 +1,67 @@ +where('assignment_link_ttl_hours', 24) + ->update(['assignment_link_ttl_hours' => 1]); + + Schema::table('settings', function (Blueprint $table) { + $table->unsignedInteger('assignment_link_ttl_hours')->default(1)->change(); + }); + + // --------------------------------------------------------------- + // Indexes. Every Eloquent query on these soft-deleting models appends + // `deleted_at is null`, and the dashboard/listing scopes filter on + // attivo/prioritario and range-scan assigned_at — all unindexed. + // --------------------------------------------------------------- + Schema::table('territori', function (Blueprint $table) { + $table->index(['attivo', 'deleted_at'], 'territori_attivo_deleted_at_index'); + $table->index('prioritario', 'territori_prioritario_index'); + }); + + Schema::table('proclamatori', function (Blueprint $table) { + $table->index('deleted_at', 'proclamatori_deleted_at_index'); + }); + + // (territorio_id, returned_at) is already indexed by the create + // migration; only the assigned_at range scans are missing. + Schema::table('assegnazioni', function (Blueprint $table) { + $table->index('assigned_at', 'assegnazioni_assigned_at_index'); + }); + } + + public function down(): void + { + Schema::table('assegnazioni', function (Blueprint $table) { + $table->dropIndex('assegnazioni_assigned_at_index'); + }); + + Schema::table('proclamatori', function (Blueprint $table) { + $table->dropIndex('proclamatori_deleted_at_index'); + }); + + Schema::table('territori', function (Blueprint $table) { + $table->dropIndex('territori_prioritario_index'); + $table->dropIndex('territori_attivo_deleted_at_index'); + }); + + Schema::table('settings', function (Blueprint $table) { + $table->unsignedInteger('assignment_link_ttl_hours')->default(24)->change(); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 199f8de..fc89323 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,6 +2,7 @@ namespace Database\Seeders; +use App\Models\Setting; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\App; @@ -11,6 +12,13 @@ class DatabaseSeeder extends Seeder { $this->call([RolesAndPermissionsSeeder::class]); + // Materialize the settings singleton up front. Setting::instance() + // falls back to firstOrCreate() on read, and read paths run on every + // page (Territorio scopes and accessors) — so on a fresh database two + // concurrent first requests can each insert a row, leaving two + // "singletons" with edits landing in whichever one wins. + Setting::instance(); + if (App::environment('local') && env('SEED_DEV_DATA', false)) { $this->call([DevSeeder::class]); } diff --git a/database/seeders/RolesAndPermissionsSeeder.php b/database/seeders/RolesAndPermissionsSeeder.php index 0c89aec..b5bb6a0 100644 --- a/database/seeders/RolesAndPermissionsSeeder.php +++ b/database/seeders/RolesAndPermissionsSeeder.php @@ -31,13 +31,23 @@ class RolesAndPermissionsSeeder extends Seeder Permission::firstOrCreate(['name' => $permission]); } + // givePermissionTo() rather than syncPermissions(): sync *detaches* + // anything not in these lists. This seeder can re-run against a + // populated database (the deploy marker lives on a volume that may be + // recreated), and syncing would silently revoke every permission an + // administrator had granted through the UI. Granting is additive and + // therefore safe to repeat. + // + // These lists are the baseline each role starts with, not a + // continuously enforced contract. + // Amministratore: all permissions $admin = Role::firstOrCreate(['name' => 'amministratore']); - $admin->syncPermissions($permissions); + $admin->givePermissionTo($permissions); // Assistente: proclamatori, campagne, assign, return $assistente = Role::firstOrCreate(['name' => 'assistente']); - $assistente->syncPermissions([ + $assistente->givePermissionTo([ 'proclamatori.manage', 'campagne.manage', 'territori.assign', @@ -47,7 +57,7 @@ class RolesAndPermissionsSeeder extends Seeder // Operatore: assign and return only $operatore = Role::firstOrCreate(['name' => 'operatore']); - $operatore->syncPermissions([ + $operatore->givePermissionTo([ 'territori.assign', 'territori.return', ]); diff --git a/resources/views/livewire/proclamatori/proclamatore-index.blade.php b/resources/views/livewire/proclamatori/proclamatore-index.blade.php index 5257808..72ebca7 100644 --- a/resources/views/livewire/proclamatori/proclamatore-index.blade.php +++ b/resources/views/livewire/proclamatori/proclamatore-index.blade.php @@ -55,7 +55,7 @@ Inattivo @endif -