fix(ui): make modal cancel buttons work; serve the app over the HTTPS domains

The cancel buttons were dead: wire-elements registers Livewire.on('closeModal'),
but the buttons used Alpine's $dispatch, which fires a DOM event. Livewire
bridges its own events TO the DOM, not back, so nothing ever received them.
Proven in the browser both ways — before: modal stays open after Abbrechen;
after: it closes.

Domain operation behind Zoraxy:
- trustProxies for FOR/PORT/PROTO so Laravel sees https (otherwise it builds
  http:// URLs into an https page). X_FORWARDED_HOST is deliberately NOT
  trusted — the console is gated on the request host, and trusting it would let
  anyone reach /admin through a public domain by forging the header.
- APP_URL + VITE_REVERB_* point at the dev domains (wss via ws.dev).
- Vite dev server is now opt-in (VITE_AUTOSTART): over https the browser cannot
  load assets from http://<ip>:5173. Built assets are the default, and the
  entrypoint clears a stale public/hot, which otherwise 404s every asset.

Verified in the browser over https: login, styled pages, zero console errors,
zero unencrypted requests, /admin reachable only on admin.dev.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 21:24:03 +02:00
parent 8edb2a69e5
commit 3e82b51f89
10 changed files with 39 additions and 9 deletions

View File

@ -63,8 +63,8 @@ REVERB_SERVER_PORT=8080
# browser-side (baked into built assets): the address a browser can reach
VITE_REVERB_APP_KEY=change-me-app-key
VITE_REVERB_HOST=localhost
VITE_REVERB_PORT=8080
VITE_REVERB_SCHEME=http
VITE_REVERB_PORT=443
VITE_REVERB_SCHEME=https
VITE_APP_NAME=CluPilot
# Vite HMR host the browser connects to (dev). Set to the reachable VM address
@ -174,6 +174,10 @@ ADMIN_HOSTS=admin.dev.clupilot.com,10.10.90.185,localhost,127.0.0.1
HOST_UID=1000
HOST_GID=1000
APP_PORT=80
# Vite dev server (HMR). Off by default: over the HTTPS domains the
# browser cannot load assets from http://<ip>:5173. Set true only when
# working over http://10.10.90.185, then run: docker compose up -d app
VITE_AUTOSTART=false
VITE_PORT=5173
REVERB_HOST_PORT=8080
DB_HOST_PORT=3306

View File

@ -24,6 +24,22 @@ return Application::configure(basePath: dirname(__DIR__))
// that an operator console is hosted here. Self-scoped to /admin*.
$middleware->prependToGroup('web', \App\Http\Middleware\RestrictAdminHost::class);
// TLS is terminated by the reverse proxy (Zoraxy on the private LAN), so
// without this Laravel sees plain http and builds http:// URLs into an
// https page — every asset and redirect breaks as mixed content.
//
// X_FORWARDED_HOST is deliberately NOT trusted: the console is gated on
// the request host (ADMIN_HOSTS), and trusting that header would let
// anyone reach /admin through a public domain by sending
// "X-Forwarded-Host: admin.…". Zoraxy passes the real Host through, so
// nothing needs it.
$middleware->trustProxies(
at: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '127.0.0.1'],
headers: Request::HEADER_X_FORWARDED_FOR
| Request::HEADER_X_FORWARDED_PORT
| Request::HEADER_X_FORWARDED_PROTO,
);
// Stripe posts server-to-server with its own signature (no CSRF token).
$middleware->validateCsrfTokens(except: ['webhooks/stripe']);
})

View File

@ -23,6 +23,8 @@ services:
environment:
VITE_HMR_HOST: ${VITE_HMR_HOST:-localhost}
VITE_PORT: ${VITE_PORT:-5173}
# Vite dev server is opt-in — see docker/supervisor/clupilot.conf.
VITE_AUTOSTART: ${VITE_AUTOSTART:-false}
depends_on:
mariadb:
condition: service_healthy

View File

@ -18,6 +18,10 @@ elif printf '%s ' "$@" | grep -qE 'reverb:start|queue:work|schedule:work'; then
fi
if [ "$role" = "app" ]; then
# A container that was killed leaves public/hot behind, and Laravel then
# serves @vite from a dev server that is not running — every asset 404s.
# Vite recreates the file when it actually starts.
rm -f public/hot
[ -f vendor/autoload.php ] || www "composer install --no-interaction --prefer-dist --no-progress"
[ -x node_modules/.bin/vite ] || www "npm install --no-fund --no-audit"
if [ -f .env ] && ! grep -qE '^APP_KEY=.+' .env; then

View File

@ -20,6 +20,10 @@ stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:vite]
; Opt-in: over HTTPS domains the browser cannot load assets from
; http://<ip>:5173 (mixed content + CORS), so the default is the built manifest.
; Set VITE_AUTOSTART=true in .env for HMR while working over the IP.
autostart=%(ENV_VITE_AUTOSTART)s
command=npm run dev -- --host 0.0.0.0
directory=/var/www/html
user=www-data

View File

@ -10,7 +10,7 @@
</div>
</div>
<div class="mt-6 flex justify-end">
<x-ui.button variant="secondary" x-on:click="$dispatch('closeModal')">{{ __('datacenters.cancel') }}</x-ui.button>
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('datacenters.cancel') }}</x-ui.button>
</div>
@else
<div class="flex items-start gap-3">
@ -23,7 +23,7 @@
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="$dispatch('closeModal')">{{ __('datacenters.cancel') }}</x-ui.button>
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('datacenters.cancel') }}</x-ui.button>
<x-ui.button variant="danger" wire:click="delete" wire:loading.attr="disabled">
<x-ui.icon name="trash-2" class="size-4" />{{ __('datacenters.delete_confirm') }}
</x-ui.button>

View File

@ -9,7 +9,7 @@
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="$dispatch('closeModal')">{{ __('hosts.cancel') }}</x-ui.button>
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('hosts.cancel') }}</x-ui.button>
<x-ui.button variant="danger" wire:click="remove" wire:loading.attr="disabled">
<x-ui.icon name="trash-2" class="size-4" />{{ __('hosts.remove_confirm') }}
</x-ui.button>

View File

@ -26,7 +26,7 @@
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="$dispatch('closeModal')">{{ __('datacenters.cancel') }}</x-ui.button>
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('datacenters.cancel') }}</x-ui.button>
<x-ui.button variant="primary" wire:click="save" wire:loading.attr="disabled">{{ __('datacenters.save') }}</x-ui.button>
</div>
</div>

View File

@ -22,7 +22,7 @@
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="$dispatch('closeModal')">{{ __('settings.keep') }}</x-ui.button>
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('settings.keep') }}</x-ui.button>
<x-ui.button variant="danger" wire:click="cancelPackage" wire:loading.attr="disabled">
{{ __('settings.cancel_confirm') }}
</x-ui.button>

View File

@ -10,7 +10,7 @@
</div>
</div>
<div class="mt-6 flex justify-end">
<x-ui.button variant="secondary" x-on:click="$dispatch('closeModal')">{{ __('settings.keep') }}</x-ui.button>
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('settings.keep') }}</x-ui.button>
</div>
@else
<div class="flex items-start gap-3">
@ -28,7 +28,7 @@
@error('confirmWord')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="$dispatch('closeModal')">{{ __('settings.keep') }}</x-ui.button>
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('settings.keep') }}</x-ui.button>
<x-ui.button variant="danger" wire:click="closeAccount" wire:loading.attr="disabled">
{{ __('settings.close_confirm') }}
</x-ui.button>