name('overview'); Route::get('/customers', Admin\Customers::class)->name('customers'); Route::get('/instances', Admin\Instances::class)->name('instances'); Route::get('/hosts', Admin\Hosts::class)->name('hosts'); Route::get('/hosts/create', Admin\HostCreate::class)->name('hosts.create'); Route::get('/hosts/{host}', Admin\HostDetail::class)->name('hosts.show'); Route::get('/datacenters', Admin\Datacenters::class)->name('datacenters'); Route::get('/plans', Admin\Plans::class)->name('plans'); Route::get('/plans/{uuid}', Admin\PlanVersions::class)->name('plans.versions'); // POST so the identity change is CSRF-protected (a GET could be forced cross-site). Route::post('/impersonate/{customer}', [ImpersonationController::class, 'start'])->name('impersonate'); Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning'); Route::get('/maintenance', Admin\Maintenance::class)->name('maintenance'); Route::get('/vpn', Admin\Vpn::class)->name('vpn'); Route::get('/revenue', Admin\Revenue::class)->name('revenue'); Route::get('/finance', Admin\Finance::class)->name('finance'); Route::get('/invoices', Admin\Invoices::class)->name('invoices'); // The PDF, rendered on demand from the frozen document. A plain route rather // than a Livewire action: a download is a download, and routing one through a // component round-trip only adds a way for it to fail. Route::get('/invoices/{uuid}/pdf', function (string $uuid) { Illuminate\Support\Facades\Gate::authorize('site.manage'); $invoice = App\Models\Invoice::query()->where('uuid', $uuid)->firstOrFail(); return response()->streamDownload( fn () => print (app(App\Services\Billing\InvoiceRenderer::class)->forInvoice($invoice)), $invoice->number.'.pdf', ['Content-Type' => 'application/pdf'], ); })->name('invoices.pdf'); Route::get('/mail', Admin\Mail::class)->name('mail'); Route::get('/integrations', Admin\Integrations::class)->name('integrations'); // The former admin.secrets and admin.infrastructure pages, merged into the // one above — grouped by what each value configures, not by which of the two // mechanisms (SecretVault vs App\Support\Settings) happens to hold it. Kept // as redirects, permanently, so a bookmark or a link out in the wild does not // 404 — the page has genuinely moved, the same reasoning legal.status already // uses in routes/web.php. Route::get('/secrets', fn () => redirect()->route('admin.integrations', status: 301))->name('secrets'); Route::get('/infrastructure', fn () => redirect()->route('admin.integrations', status: 301))->name('infrastructure'); Route::get('/settings', Admin\Settings::class)->name('settings'); // Its own route so RequireOperatorTwoFactor can exempt ENROLMENT without // exempting the rest of admin.settings — see that middleware for why. Route::get('/two-factor-setup', Admin\TwoFactorSetup::class)->name('two-factor-setup'); // POST so Laravel's CSRF middleware protects it, like every other state change. Route::post('/logout', function () { Auth::guard('operator')->logout(); // NOT session()->invalidate(): where the console and the portal share a // host (shared/fallback mode — this VM's own mode), both guards keep // their login key in the SAME session. invalidate() is flush() + // migrate(true) — it discards every attribute in the session, not just // the operator's, so it would silently sign a customer out of the portal // too if the same browser happened to carry both. regenerate() issues a // new session id (no fixation risk) without touching the other // attributes, so the 'web' guard's own login key — already removed for // 'operator' by the logout() call above — survives untouched. session()->regenerate(); session()->regenerateToken(); return redirect()->route('admin.login'); })->name('logout'); /* * A deployment restarts the very application that is watching it. * * The settings card polls itself with wire:poll, which is fine until the run * reaches the restart: the requests fail, and what comes back afterwards is a * new build being asked questions by the old page's JavaScript. So the card * never learned that the update had finished and an operator had to reload to * find out — reported exactly that way. * * This is the smallest thing that can answer "is it done, and is it still the * same build" across that gap: no Livewire, no component state, no assets. A * failed request here is expected (it IS the restart) and the poller keeps * trying rather than giving up. */ Route::get('/update/state', function (UpdateChannel $channel) { $state = $channel->state(); // Only once a run has actually started, not while merely queued: before // that, lastLog() would return the PREVIOUS run's leftovers, which would // sit under a "starts in 0:47" countdown looking like the current run // already has a history. $started = $state['started_at'] !== null; return response()->json([ 'running' => $state['running'], 'commit' => $state['commit'], 'behind' => $state['behind'], 'last_finished_at' => $state['last_finished_at']?->toIso8601String(), // Already localized ("Schritt: …"), the same sentence the settings // card itself renders — so the maintenance overlay (layouts/admin) // only ever displays a ready-made string and never assembles German // text in JavaScript. 'step' => $state['phase'] !== null ? __('admin_settings.update_step', ['step' => $state['phase']]) : null, // Ready to print — the same sentence update_since renders on the // settings card — so the overlay never assembles it either. 'running_since' => $started ? __('admin_settings.update_since', ['when' => $state['started_at']->diffForHumans()]) : null, // The tail of the run log: a run that is genuinely stuck is then // visibly stuck AT a step, instead of a spinner nobody can read // anything from. 'log' => $started ? $channel->lastLog() : null, ])->header('Cache-Control', 'no-store'); })->name('update.state');