Compare commits

..

No commits in common. "v1.3.3" and "feat/bootstrap" have entirely different histories.

738 changed files with 612 additions and 80554 deletions

View File

@ -38,7 +38,7 @@ REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
SESSION_DRIVER=database
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
@ -63,198 +63,23 @@ 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=443
VITE_REVERB_SCHEME=https
VITE_REVERB_PORT=8080
VITE_REVERB_SCHEME=http
VITE_APP_NAME=CluPilot
# Vite HMR host the browser connects to (dev). Set to the reachable VM address
# when developing against a remote host; localhost for local-only.
VITE_HMR_HOST=localhost
# ── Mail ─────────────────────────────────────────────────────────────────
# DEV: `log` writes mails to storage/logs (nothing is sent).
# PROD: switch to smtp and fill in the credentials below.
# ── Mail (dev → log) ─────────────────────────────────────────────────────
MAIL_MAILER=log
MAIL_FROM_ADDRESS=hello@clupilot.local
MAIL_FROM_NAME=CluPilot
# MAIL_MAILER=smtp
# MAIL_HOST=smtp.your-provider.tld
# MAIL_PORT=587
# MAIL_USERNAME=
# MAIL_PASSWORD=
# MAIL_SCHEME=smtp # smtp | smtps (465)
# ═════════════════════════════════════════════════════════════════════════
# CluPilot operations — fill these in to run real provisioning.
# Everything below is OPTIONAL for local dev: left blank, the services are
# no-ops/fakes and the app + test suite still run. Only real infrastructure
# work (host onboarding, customer provisioning) needs them.
# ═════════════════════════════════════════════════════════════════════════
# ── Stripe (payments) ────────────────────────────────────────────────────
# WEBHOOK_SECRET is READ BY CODE today (signature check on /webhooks/stripe).
# Outside local/testing a missing secret makes the webhook fail closed.
STRIPE_WEBHOOK_SECRET=
# Not read yet — needed once the Checkout flow is built (currently the portal
# only records a purchase intent):
STRIPE_KEY=
STRIPE_SECRET=
# ── DNS (Hetzner DNS API) ────────────────────────────────────────────────
# Creates <subdomain>.<zone> A-records for each customer instance.
HETZNER_DNS_TOKEN=
CLUPILOT_DNS_ZONE=clupilot.com
# Host names (fsn-01.node.…) are NOT published here — that would leak the
# internal WireGuard subnet. They go into this directory instead, which the
# vpn-dns container reads (dnsmasq --hostsdir) over the dns-hosts volume; see
# docker-compose.yml. Only change if that volume is mounted somewhere else.
CLUPILOT_DNS_HOSTS_DIR=/etc/clupilot/dns-hosts
# ── Traefik (reverse proxy + TLS) ────────────────────────────────────────
# Directory ON THE PROXMOX HOST where CluPilot writes dynamic route files.
TRAEFIK_DYNAMIC_PATH=/etc/traefik/dynamic
# ── WireGuard hub (this CluPilot VM) ─────────────────────────────────────
# Hosts join this hub during onboarding; CluPilot reaches them over the tunnel.
CLUPILOT_WG_SUBNET=10.66.0.0/24
CLUPILOT_WG_HUB_IP=10.66.0.1
CLUPILOT_WG_ENDPOINT= # public host:port peers dial, e.g. vpn.clupilot.com:51820
CLUPILOT_WG_HUB_PUBKEY= # `wg pubkey < /etc/wireguard/privatekey`
CLUPILOT_WG_CONFIG_PATH=/etc/wireguard/wg0.conf
# ── SSH identity for host onboarding ─────────────────────────────────────
# Deployed to each fresh server after the one-time root password login.
# PREFERRED: point at files (a multi-line PEM cannot live in .env).
# ssh-keygen -t ed25519 -N '' -C clupilot -f storage/app/ssh/clupilot
CLUPILOT_SSH_PUBLIC_KEY_PATH=
CLUPILOT_SSH_PRIVATE_KEY_PATH=
# Alternative (single-line values only):
CLUPILOT_SSH_PUBLIC_KEY=
CLUPILOT_SSH_PRIVATE_KEY=
CLUPILOT_SSH_COMMAND_TIMEOUT=2000
# ── Monitoring ───────────────────────────────────────────────────────────
# The built-in client speaks a GENERIC REST API:
# GET/POST /monitors, GET/DELETE /monitors/{id}
# For Uptime Kuma use the bundled bridge below (Kuma's own REST API is
# read-only — monitor CRUD goes through Socket.IO):
# MONITORING_API_URL=http://kuma-bridge:8080
# Leave blank to disable monitoring entirely: provisioning records a stable
# breadcrumb and its own health checks (occ status, TLS, admin user) still
# gate acceptance.
MONITORING_API_URL=
MONITORING_API_TOKEN=
# ── Uptime Kuma (über die mitgelieferte Bridge) ───────────────────────────
# Kuma kann Monitore nur über Socket.IO anlegen, daher die Bridge:
# docker compose --profile monitoring up -d --build kuma-bridge
# Danach hier MONITORING_API_URL auf die Bridge zeigen lassen:
# MONITORING_API_URL=http://kuma-bridge:8080
# MONITORING_API_TOKEN ist gleichzeitig das Bridge-Token (frei wählbar, lang).
KUMA_URL=
KUMA_USERNAME=
KUMA_PASSWORD=
KUMA_TOTP=
# Monitoring-Verhalten bei Ausfall:
# MONITORING_REQUIRED=false -> Bereitstellung läuft weiter (Warn-Event), Standard
# MONITORING_REQUIRED=true -> Lauf schlägt fehl, wenn Monitoring nicht erreichbar
MONITORING_REQUIRED=false
MONITORING_ATTEMPTS=2
# ── VPN-Konfigurationen ──────────────────────────────────────────────────
# Schlüssel für hier gespeicherte WireGuard-Konfigurationen (32 Byte, base64).
# Bewusst getrennt von APP_KEY. Leer = Speichern deaktiviert.
# Erzeugen: head -c 32 /dev/urandom | base64
VPN_CONFIG_KEY=
# ── Zugangsdaten der Konsole (Stripe, DNS, Monitoring, Postfächer) ────────
# Eigener Schlüssel, ebenfalls getrennt von APP_KEY — der wird routinemäßig
# gewechselt und würde sonst jedes gespeicherte Geheimnis unlesbar machen.
# LEER = die Konsole speichert gar keine Zugangsdaten und sagt das auch.
# Erzeugen: head -c 32 /dev/urandom | base64
SECRETS_KEY=
# Netze, die als "wir" gelten, solange die Website versteckt ist
# (WireGuard-Subnetz). Alles andere sieht die Platzhalterseite.
TRUSTED_RANGES=10.66.0.0/24,127.0.0.1
# Umsatzsteuersatz des Verkäufers in Prozent. Preise in der Konfiguration
# sind NETTO; die Oberfläche weist beides aus.
CLUPILOT_TAX_PERCENT=20
# ── Wo die Konsole antwortet ─────────────────────────────────────────────
#
# ADMIN_HOSTS: Hostnamen, unter denen die Konsole erreichbar ist. Der ERSTE
# ist der kanonische — für ihn werden URLs erzeugt; die weiteren sind
# Ausweichadressen, über die jemand hereinkommt, der ausgesperrt ist.
#
# ADMIN_HOST_EXCLUSIVE: darf auf diesen Namen NUR die Konsole antworten?
#
# true Die Konsole liegt an der WURZEL ihres Hostnamens
# (https://admin.…/), und Portal wie Website antworten dort mit 404.
# Dann gehört hier ausschliesslich der Konsolen-Name hinein — eine
# IP oder localhost mit aufzunehmen schaltet Portal und Website auf
# genau dieser Adresse ab. Das ist die Betriebsform.
#
# false Die Konsole liegt unter /admin auf jedem gelisteten Namen, und
# diese Namen bedienen weiterhin auch Portal und Website. Nur für
# eine Maschine ohne eigene DNS-Namen sinnvoll.
#
# Ohne Eintrag gilt false — dann zeigt https://admin.…/ das PORTAL, nicht die
# Konsole, und das sieht wie ein Fehler aus, obwohl es die Vorgabe ist.
#
# APP_HOST: Hostname des Kundenportals, z. B. app.clupilot.com. Gesetzt, ist
# JEDE Portal-Route an diesen Host gebunden und existiert nirgends sonst —
# www.clupilot.com/dashboard ist dann ein 404, kein funktionierender Aufruf.
# Leer = Portal antwortet ueberall (Vorgabe, und was jede dev-Maschine ueber
# eine blanke IP braucht).
APP_HOST=
# SITE_HOST: Hostname der oeffentlichen Website. Gesetzt, antwortet die
# Startseite NUR dort — jeder andere Host (das Portal, eine blanke IP) zeigt
# unter "/" die Anmeldung bzw. das Dashboard. Leer heisst: Startseite ueberall,
# Mehrere Namen kommagetrennt, der ERSTE ist der kanonische — er liefert aus,
# alle weiteren leiten dauerhaft dorthin um (Pfad und Query bleiben erhalten):
# SITE_HOST=www.clupilot.com,clupilot.com
# und dann liefert app.clupilot.com die Website aus. Gebunden sind Startseite,
# robots.txt und die Legal-Seiten — dadurch erzeugt route('legal.impressum')
# auch aus einer Mail heraus eine www-Adresse.
SITE_HOST=
ADMIN_HOSTS=admin.dev.clupilot.com
ADMIN_HOST_EXCLUSIVE=true
# ── Nextcloud blueprint template ─────────────────────────────────────────
# NOT an env var: set the Proxmox template VMID per plan in
# config/provisioning.php → plans.*.template_vmid (default 9000).
# ── docker-compose knobs (host-side) ─────────────────────────────────────
HOST_UID=1000
HOST_GID=1000
# Behind a reverse proxy — which is every production install — these must stay
# on loopback: Docker publishes ports ahead of UFW, so anything on 0.0.0.0 is
# reachable from the internet regardless of the firewall, and reaching a backend
# directly skips every hostname and address rule the proxy enforces.
# For local development without a proxy, set APP_PORT=80.
APP_PORT=127.0.0.1:8080
# 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
APP_PORT=80
VITE_PORT=5173
REVERB_HOST_PORT=127.0.0.1:8081
REVERB_HOST_PORT=8080
DB_HOST_PORT=3306
# ── CI (Gitea Actions) ──────────────────────────────────────────────────────
# Registrierungs-Token aus Gitea: Repo → Settings → Actions → Runners →
# "Create new runner". Danach: docker compose --profile ci up -d runner
GITEA_INSTANCE_URL=https://git.bave.dev
GITEA_RUNNER_TOKEN=
GITEA_RUNNER_NAME=clupilot-local
# Passend zur Server-Version halten (1.20 verträgt keinen aktuellen Runner).
GITEA_RUNNER_VERSION=0.2.6
# Zone in der Zeiten ANGEZEIGT werden. Gespeichert wird immer UTC.
APP_DISPLAY_TIMEZONE=Europe/Vienna

View File

@ -1,118 +0,0 @@
# CluPilot CI — runs on Gitea Actions (GitHub-Actions syntax, own runner).
#
# Deliberately not mirrored to GitHub: this repository describes how our
# infrastructure is provisioned, and the same workflow runs at home. If we ever
# move, this file goes along unchanged.
name: tests
on:
push:
branches: [main, 'feat/**']
pull_request:
jobs:
pest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2
- name: Set up PHP
# Pinned: the floating v2 tag now requires node24, which the runner
# matching Gitea 1.20 cannot execute. 2.34.1 is the newest that still
# declares node20.
uses: shivammathur/setup-php@2.34.1
with:
php-version: '8.4'
extensions: mbstring, pdo_sqlite, sodium, redis, bcmath, gd, zip
coverage: none
- name: Install PHP dependencies
# Source clones, not dist archives: dist downloads go through GitHub's
# API, which throttles anonymous callers and left a half-installed
# vendor/ behind — the tests then failed with 500s that had nothing to
# do with the code. Cloning is slower and does not need anyone's quota.
# Swap back to --prefer-dist once a GitHub token is configured.
run: composer install --no-interaction --prefer-source --no-progress
- name: Prepare environment
run: |
cp .env.example .env
php artisan key:generate
- name: Tests
# phpunit.xml pins its own sqlite/array drivers, so no services needed.
run: ./vendor/bin/pest --colors=always
assets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2
- uses: actions/setup-node@v4.1.0
with:
node-version: '22'
- name: Install JS dependencies
run: npm ci --no-fund --no-audit
# A build failure here is what used to surface as "the design is broken"
# only after deploying — catch it before it ships.
- name: Build assets
run: npm run build
release:
# Only a green run produces the tag the console offers as an update.
needs: [pest, assets]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Tag this commit as tested
run: |
tag="tested-$(date -u +%Y%m%d-%H%M)-$(git rev-parse --short HEAD)"
git tag "$tag"
git push origin "$tag"
# A release is cut by editing VERSION and merging it — nothing else. The
# tag is created here, only after the suite is green, and only if it does
# not already exist. Never moved: servers are pinned to these, and a tag
# that changes underneath them means two machines claiming one version.
- name: Tag the release when VERSION changed
run: |
version="$(tr -d ' \n\r' < VERSION)"
# Anchored, because a `case` glob does not anchor: `[0-9]*.[0-9]*`
# happily accepts 1x.2y.3garbage and would tag it.
printf '%s' "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' \
|| { echo "VERSION is not MAJOR.MINOR.PATCH: '$version'" >&2; exit 1; }
# Only the commit that RAISED the version is the release. Without
# this, any later green push finds the tag missing and claims it —
# and the tag would then name code the bump never described. Runs
# overlap and finish out of order; that is enough for it to happen.
previous="$(git show 'HEAD^:VERSION' 2>/dev/null | tr -d ' \n\r' || true)"
if [ "$previous" = "$version" ]; then
echo "::notice::VERSION is unchanged ($version) — nothing to release."
exit 0
fi
tag="v${version}"
git fetch --tags --force origin
if existing="$(git rev-parse -q --verify "refs/tags/${tag}^{commit}")"; then
# Already released. The invariant is that the tag points at the
# commit VERSION was raised in — if a later commit still carries
# that number, that is fine, but the tag must not be re-pointed.
if [ "$existing" != "$(git rev-parse HEAD)" ]; then
echo "::notice::${tag} already exists at ${existing}; leaving it alone."
fi
exit 0
fi
git config user.name "CluPilot CI"
git config user.email "ci@clupilot.local"
git tag -a "$tag" -m "CluPilot ${version}"
git push origin "$tag"
echo "::notice::Released ${tag}"

12
.gitignore vendored
View File

@ -3,10 +3,6 @@
.env
.env.backup
.env.production
# EnvFileEditor's own backups (App\Services\Env\EnvFileEditor::backup()) — a
# timestamped copy beside .env before every write from the console's raw
# editor. Never pruned automatically; see the warning on that page.
.env.bak-*
.phpactor.json
.phpunit.result.cache
/.codex
@ -24,10 +20,6 @@
/public/storage
/storage/*.key
/storage/pail
# Exported invoice PDFs — runtime output, and on a real server this is a mount
# point. Committing it would put customer documents into the repository and
# make a deploy fight the archive for the directory.
/storage/archive
/vendor
_ide_helper.php
Homestead.json
@ -36,7 +28,3 @@ Thumbs.db
# Gitea push token (parent home dir; never track)
.env.gitea
clupilot.env
# SDD-Arbeitsverzeichnis (Ledger, Briefs, Review-Pakete) — Kladde, nicht Quelltext.
.superpowers/

269
CLAUDE.md
View File

@ -1,269 +0,0 @@
# CluPilot — verbindliche Regeln (Repo-Teil)
Die **Vollfassung der Regeln R1R17 liegt beim Nutzer**, nicht im Repo. Die
Kurzfassung steht in `docs/handoffs/2026-07-25-clupilot-state-handoff.md` §9.
Bei Konflikt: **STOP & fragen.**
Diese Datei hält die Regeln fest, die aus konkreten Fehlern im laufenden Betrieb
entstanden sind — sie sind nicht verhandelbar und werden per Test erzwungen.
---
## R18 — Icon-Größe und Zeilenumbruch
**Ein Icon steht neben seinem Text, nie darüber, und nie größer als bestellt.**
Verboten:
1. **Icon zwingt den Text auf eine zweite Zeile.** Ein Navigationseintrag, ein
Button, ein Tabellen-Action ist **einzeilig**. Zwei Zeilen sind nur erlaubt,
wenn der Text selbst bewusst zweizeilig gesetzt ist (Label + Unterzeile) —
dann steht das Icon links davon, nicht darüber.
2. **Icon größer als die Zeile, in der es sitzt.** Standard ist `size-5` (20px)
in der Navigation, `size-4` (16px) in Buttons, Tabellen und Fließtext.
Größer nur, wenn es als eigenständiges Element gemeint ist (Leerzustand,
Statusplakette).
### Warum das zweimal schiefging
- **Zeilenumbruch.** Tailwinds Preflight setzt `svg { display: block }`. Ein
Icon in einem *inline*-Elternteil schiebt den folgenden Text damit auf die
nächste Zeile. Genau so wurde aus dem Eintrag „Zugangsdaten" ein doppelt so
hoher Kasten mit Schloss oben und Wort darunter.
- **Größe.** `.size-4` und `.size-5` haben **dieselbe Spezifität**, also
entscheidet die Reihenfolge im Stylesheet — und Tailwind gibt `.size-4` *vor*
`.size-5` aus. Eine Komponente, die `size-5` bedingungslos mitmergt, überstimmt
damit **jedes** `class="size-4"` am Aufrufort. Alle so geschriebenen Icons
liefen still auf 20px.
### Wie es jetzt gebaut ist
- `resources/views/components/ui/icon.blade.php` setzt seine Standardgröße
**nur**, wenn der Aufrufort keine `size-`/`w-`/`h-`-Klasse mitgibt, und
rendert `inline-block shrink-0 align-middle` statt des Preflight-`block`.
- `resources/views/components/ui/nav-item.blade.php` legt Label und Icon in eine
eigene Flex-Zeile, damit ein Icon auch im falschen Slot daneben landet.
- Icons in `<x-ui.nav-item>` gehören in `<x-slot:icon>`, nicht in den
Default-Slot.
### Erzwungen durch
`tests/Feature/IconLayoutTest.php` — Größe des Aufruforts gewinnt, Icon bleibt
`inline-block`, Nav-Eintrag bleibt einzeilig aus beiden Slots, und kein
Blade-File im Repo darf ein Icon am Icon-Slot vorbeischmuggeln.
---
## R19 — Zeitzone: gespeichert in UTC, angezeigt auf der Wanduhr
**Jede Zeit, die ein Mensch liest, geht vorher durch `->local()`.**
Verboten:
1. **Einen gespeicherten Zeitstempel direkt formatieren.** `$model->created_at->isoFormat(…)`
liefert UTC. Richtig ist `$model->created_at->local()->isoFormat(…)`.
2. **`->timezone(config('app.timezone'))`.** Das ist die *Speicher*zone und bleibt
UTC — der Aufruf sieht aus wie eine Umrechnung und ist keine.
3. **Ein `datetime-local`-Feld nur in eine Richtung behandeln.** Rein und raus
gehören zusammen: `LocalTime::toField()` und `LocalTime::fromField()`.
Ein Feld hat keine Zeitzone; es sind die Ziffern, die jemand auf der eigenen
Uhr abliest.
Ausgenommen: `diffForHumans()` ist relativ und in jeder Zone gleich.
### Warum das durchgerutscht ist
Die Konsole kündigte eine Aktualisierung „spätestens um 15:21" an, während die
Uhr 17:21 zeigte. Zwei der vierzehn betroffenen Ansichten sahen sogar behandelt
aus — sie riefen `->timezone(config('app.timezone'))`, was sich liest wie „in
Ortszeit umrechnen" und, weil diese Zone UTC ist, nichts tut. Eine Attrappe ist
schlimmer als gar kein Aufruf: sie hält den Nächsten vom Nachsehen ab.
Schlimmer als die Anzeigen waren zwei **Formulare**: Wartungsfenster und
Paketversionen füllten ihre Felder mit UTC und lasen sie als UTC zurück. Ein
eingetragenes „21:00" wurde zu 23:00 Ortszeit.
Und die Tests deckten es nicht auf, weil sie den erwarteten Wert **mit demselben
falschen Aufruf** bildeten. Ein Test, der die Implementierung nachrechnet, prüft
nichts.
### Wie es jetzt gebaut ist
- `config('app.display_timezone')` (`APP_DISPLAY_TIMEZONE`, Vorgabe
`Europe/Vienna`) getrennt von `app.timezone`, das UTC bleibt.
- `->local()` als Carbon-Makro in `AppServiceProvider`. Es **kopiert** vor dem
Umstellen: `Illuminate\Support\Carbon` ist mutabel, sonst schriebe das bloße
Anzeigen das Modellattribut um. Beide Klassen bekommen denselben Rumpf —
Carbon führt Makros in *einer* globalen Tabelle, die zweite Registrierung
ersetzt die erste für alle.
- `App\Support\LocalTime` hält beide Feldrichtungen nebeneinander, damit niemand
eine ändert, ohne die andere zu sehen.
### Erzwungen durch
`tests/Feature/DisplayTimezoneTest.php` — kein Blade und kein Livewire-Bauteil
darf absolut formatieren ohne `->local()`, die UTC-Attrappe ist verboten,
Speicherzone bleibt UTC, Sommer- **und** Winterzeit werden geprüft, `->local()`
verändert das Original nicht, und der Feld-Round-Trip kommt als derselbe
Zeitpunkt zurück.
---
## R20 — Bearbeiten passiert im Modal, nie in der Zeile
**Sobald etwas Eingabefelder hat, geht ein Modal auf.**
Verboten:
1. **Inline-Bearbeitung in einer Tabellenzeile.** Kein `<input>`, kein
`<textarea>` in einem `<td>`. Die Zeile wächst, die Spalten daneben springen,
und eine halb im Bearbeitungsmodus stehende Tabelle liest sich wie ein
Darstellungsfehler, nicht wie ein Formular.
2. **Ein Bearbeiten-Knopf, der eine Methode am Seiten-Bauteil aufruft.** Er
schickt `openModal` — alles andere ist der Inline-Editor unter neuem Namen.
Nicht betroffen: Formulare, die *die Seite sind* — Anlegen-Formulare, die
Einstellungsseite, die Einladen-Zeile über einer Tabelle. Die bearbeiten keinen
bestehenden Datensatz an Ort und Stelle.
Ausnahmen, die kein Modal brauchen: ein einzelnes `<select>` oder eine
Checkbox in der Zeile (Rolle umstellen, aktiv schalten). Ein Klick, ein Wert,
keine Höhenänderung.
### Warum das aufgeschrieben wurde
Das Projekt hatte das Modal längst — `EditDatacenter`, mit genau dieser
Begründung im Kopfkommentar („avoids the row-height jump of inline editing").
Die Benutzertabelle hat es einfach nicht benutzt, und ich habe die
Bearbeitung inline gebaut, obwohl das Muster danebenlag.
### Wie es jetzt gebaut ist
- `App\Livewire\EditSeat` als `ModalComponent`, geöffnet über
`$dispatch('openModal', { component: 'edit-seat', arguments: { uuid } })`.
- Ein Modal ist **ohne** die Route-Middleware der Seite erreichbar. Es löst
deshalb den Kunden selbst auf und liest den Datensatz neu, statt einer vom
Browser hydrierten Eigenschaft zu glauben.
### Erzwungen durch
`tests/Feature/EditInModalTest.php` — kein Seiten-Blade darf ein Eingabefeld in
einem `<td>` wachsen lassen, und die Benutzertabelle muss `edit-seat` per
`openModal` öffnen.
---
## R21 — Konsole und Portal teilen keine Identität
**Betreiber und Kunden sind zwei Personengruppen, nicht zwei Zustände einer.**
Verboten:
1. **Eine Rolle oder Berechtigung am `User`.** Alle siebzehn Berechtigungen sind
Konsolen-Berechtigungen; sie liegen am `operator`-Guard. Ein `users`-Datensatz
mit Konsolenzugang ist die Vermischung in klein.
2. **Eine Auth-Route, die beide Seiten bedient.** `RestrictAdminHost::SHARED`
führt nur noch `livewire/*` und `up`.
3. **Eine Anmeldeansicht für beide.** Das Portal hat Fortify, die Konsole hat
`App\Livewire\Auth\OperatorLogin`.
### Warum das aufgeschrieben wurde
Die Konsole lieferte die Anmeldeseite des Portals aus — mit „Kein Konto?
Registrieren" darauf. `register` stand nicht in `SHARED` und konnte dort auch
nicht stehen, weil eine Konsole keine Selbstregistrierung hat. Der Link führte
also zwangsläufig in eine 404. Das war kein Anzeigefehler, sondern die Identität
zweier Personengruppen in einer Tabelle.
### Erzwungen durch
`tests/Feature/IdentitySeparationTest.php`
---
## R22 — Der Aufwand richtet sich nach der Aufgabe, nicht nach dem Verfahren
**Prüfen ist ein Mittel, kein Selbstzweck. Wer für eine Tabellenumstellung
zwölf Stunden und zehn Prüfrunden braucht, hat die Aufgabe nicht verstanden.**
Verbindliche Obergrenzen:
1. **Eine Prüfrunde, eine Fix-Runde, ein Re-Review über den Fix-Diff.** Danach
wird ein offener Befund **geparkt** — mit schriftlicher Begründung — und
nicht weitergeschliffen. Ein zweiter Re-Review desselben Befundes ist
verboten.
2. **Codex/R15: höchstens zwei Runden ohne P1.** Danach gehen die Restbefunde
als Folgepunkte in den Merge Request. Bis zur Nullmeldung zu schleifen ist
kein Qualitätsmerkmal, sondern fehlende Priorisierung.
3. **Ein Review sieht nur den Diff.** Dateien, die seit dem letzten grünen
Testlauf nicht angefasst wurden, werden nicht erneut geprüft — weder von
einem Prüfer noch beim Aufräumen. Was unverändert ist, war beim letzten
grünen Lauf schon in Ordnung.
4. **Mutationstests nur an der Grenze**, an der ein Fehler Daten verliert,
Rechte ausweitet oder Zugangsdaten preisgibt. Nicht flächendeckend.
5. **Aufgaben ohne Entwurfsentscheidung bekommen keinen eigenen Review.** Eine
Sprachdatei, eine Umbenennung, ein Feld mehr: bauen, Tests, weiter.
6. **Unabhängige Arbeit läuft gleichzeitig.** Analysen (Review, Codex,
Testlauf) blockieren einander nicht und werden parallel gestartet.
### Warum das aufgeschrieben wurde
Die Betreiber-Identität — eine Tabelle, ein Guard, eine Anmeldeseite — hat
zehn Codex-Runden und mehrere Re-Reviews gebraucht. Die Funde waren echt, aber
die Reihenfolge war falsch: Sicherheitsgrenzen zuerst, Randfälle in den
Folgepunkt. Der Nutzer wartet auf ein Ergebnis, nicht auf ein Verfahren.
---
## R23 — Bestätigung passiert im Modal, nie im nativen Browser-Dialog
**Eine Aktion mit Folgen wird im Design dieses Produkts bestätigt — nicht in
einem Fenster, das der Browser zeichnet.**
Verboten:
1. **`wire:confirm`.** Das Attribut ruft `window.confirm()` auf, bevor die
Anfrage beim Server ankommt — eine Systemmeldung mit dem Hostnamen darin,
kein Bestandteil der Oberfläche, die dieses Produkt zeichnet, nicht
gestaltbar und nur so übersetzt, wie der Browser gerade eingestellt ist.
2. **`confirm(` aus eigenem JavaScript.** Derselbe native Dialog, nur ohne die
Livewire-Beschriftung. Ein Skript, das ihn direkt aufruft, hat dasselbe
Problem unter anderem Namen.
### Warum das aufgeschrieben wurde
Die Konsole fragte vor dem Übernehmen eines Zahlungs-Schlüssels „Auf
admin.clupilot.com wird Folgendes angezeigt: Diesen Schlüssel wirklich
übernehmen?" — ein Systemfenster mit der Domain darin, mitten im sonst
durchgestalteten Betreiber-Bereich. Sechs Stellen in beiden Bereichen taten
dasselbe: ein Schlüssel speichern oder entfernen, ein VPN-Zugang neu
ausgestellt, Zwei-Faktor entfernt (Konsole UND Portal, zwei getrennte
Identitäten nach R21), ein Benutzerzugang entzogen.
### Wie es jetzt gebaut ist
- Sechs eigene Bestätigungs-Modals nach dem Muster von `ConfirmRemoveHost` und
den anderen bestehenden Lösch-Bestätigungen — `App\Livewire\Admin\
ConfirmSaveSecret`, `ConfirmForgetSecret`, `ConfirmReissueVpnPeer`,
`ConfirmDisableTwoFactor` in der Konsole; `App\Livewire\
ConfirmDisableTwoFactor` und `ConfirmRevokeSeat` im Portal. Geöffnet über
`$dispatch('openModal', { component: '…', arguments: { … } })`, derselbe
Mechanismus, dieselbe Knopf-Reihenfolge (Abbrechen sekundär, Bestätigen
benannt statt „OK").
- Das Modal mutiert nichts selbst. Der eigentliche Vorgang (`save()`,
`forget()`, `reissue()`, `disableTwoFactor()`, `revoke()`) bleibt
unverändert auf der Seiten-Komponente stehen, mit ihren eigenen
`guard()`-/Passwort-Prüfungen. Der Bestätigen-Knopf im Modal löst nur ein
Event aus (z. B. `secret-save-confirmed`), das die Seiten-Komponente per
`#[On(...)]` auffängt und an die bestehende Methode weiterreicht — das hält
die Berechtigungsprüfung an der einen Stelle, an der sie schon stand, statt
sie im Modal zu verdoppeln.
- Der bisherige Bestätigungssatz bleibt wortgleich als `_body` stehen; nur der
neue `_title` (und bei den Zwei-Faktor-Aktionen kein weiterer Text) kam
hinzu, `_confirm` heißt jetzt wie überall sonst im Repo die kurze
Knopfbeschriftung statt eines ganzen Satzes.
### Erzwungen durch
`tests/Feature/ConfirmInModalTest.php` — kein Blade-File im Repo darf
`wire:confirm` enthalten, und kein JavaScript darf `confirm(` aufrufen.

View File

@ -1 +0,0 @@
1.3.3

View File

@ -1,459 +0,0 @@
<?php
namespace App\Actions;
use App\Models\StripePendingEvent;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* What Stripe tells us about the billing cycle, applied to the contract.
*
* The division of labour: Stripe owns the money retries, dunning, off-session
* SCA, invoice numbering and we own capability, because Stripe does not know
* how big the VM should be. So these handlers move period boundaries and status,
* and write the register. They never re-derive an amount: the invoice is the
* authority for what was charged, and recomputing it here from our own
* catalogue would produce a second, disagreeing answer.
*
* Every handler is idempotent. Stripe retries a webhook until it gets a 2xx,
* and it also sends the same event to several endpoints so "already applied"
* is the normal case, not the exception.
*/
class ApplyStripeBillingEvent
{
/**
* Precedence when two events share a second highest wins.
*
* Ordered by how much the event settles: a payment that went through is the
* last word, a snapshot of the subscription is a running picture, and a
* failed attempt is the least final of the three, since a retry may already
* have succeeded.
*/
private const RANK_PAID = 3;
private const RANK_UPDATED = 2;
private const RANK_FAILED = 1;
public function __construct(private RecordCommercialEvent $record) {}
/**
* A renewal was paid. Move the period on and enter it in the register.
*
* @param array<string, mixed> $invoice
*/
public function invoicePaid(array $invoice, ?Carbon $eventAt = null): ?SubscriptionRecord
{
$subscription = $this->resolve($invoice['subscription'] ?? null);
if ($subscription === null) {
return null;
}
$reason = (string) ($invoice['billing_reason'] ?? '');
// The checkout's own invoice is not a renewal — it is the purchase, and
// OpenSubscription has already entered it. Recording it again here
// would double every customer's first payment in the register.
if ($reason === 'subscription_create') {
return null;
}
// Only a cycle renewal moves the term on. Stripe also sends paid
// invoices for prorations and for charges raised by hand, and those are
// money received without being the start of a new month — calling them
// renewals would push the period forward on a top-up.
$isRenewal = $reason === 'subscription_cycle';
$invoiceId = (string) ($invoice['id'] ?? '');
[$start, $end] = $this->period($invoice);
// A contract that has ended stays ended (mutateInOrder refuses one), so
// a final invoice arriving after the deletion cannot hand a departed
// customer their service back. The payment is still entered in the
// register below; it did happen.
if ($isRenewal && $start !== null && $end !== null) {
$this->mutateInOrder($subscription, $eventAt, self::RANK_PAID, fn (Subscription $fresh) => $end
// Never backwards: a renewal delayed behind the next one would
// otherwise shorten the term the customer has already paid for.
->greaterThanOrEqualTo($fresh->current_period_end)
// Period boundaries are not part of the frozen snapshot:
// what the customer is owed does not change, only which
// month it is.
? [
'current_period_start' => $start,
'current_period_end' => $end,
'status' => 'active',
'stripe_status' => 'active',
]
: null);
}
// One invoice, one entry — enforced by the unique index rather than by
// a check, because Stripe can deliver the same invoice twice at once
// and both deliveries would pass a check.
$event = $isRenewal ? SubscriptionRecord::EVENT_RENEWAL : SubscriptionRecord::EVENT_INVOICE_PAID;
return $this->recordOnce(
fn () => ($this->record)(
event: $event,
subscription: $subscription->refresh(),
netCents: $isRenewal ? $subscription->price_cents : 0,
stripe: [
'invoice' => $invoiceId ?: null,
'subscription' => $subscription->stripe_subscription_id,
],
// The exact kind, kept so a proration or a manual charge stays
// distinguishable from an ordinary renewal.
extra: ['billing_reason' => $reason ?: null],
// Stripe's total is what was actually taken, tax and all. Ours
// is what was agreed; the register states both.
chargedGrossCents: isset($invoice['amount_paid']) ? (int) $invoice['amount_paid'] : null,
eventKey: $invoiceId !== '' ? "{$event}:{$invoiceId}" : null,
)
);
}
/**
* A renewal failed. Recorded, and nothing else: Stripe runs the dunning
* schedule, and cutting a customer off on the first failed attempt would
* punish an expired card as though it were a refusal to pay.
*
* @param array<string, mixed> $invoice
*/
public function invoicePaymentFailed(array $invoice, ?Carbon $eventAt = null): ?SubscriptionRecord
{
$subscription = $this->resolve($invoice['subscription'] ?? null);
if ($subscription === null) {
return null;
}
$invoiceId = (string) ($invoice['id'] ?? '');
// Only if this is the newest word we have. A failed attempt delayed
// behind the successful retry would otherwise flip a customer who has
// since paid back to past_due.
$this->mutateInOrder($subscription, $eventAt, self::RANK_FAILED, fn () => ['stripe_status' => 'past_due']);
return $this->recordOnce(
fn () => ($this->record)(
event: SubscriptionRecord::EVENT_PAYMENT_FAILED,
subscription: $subscription->refresh(),
netCents: 0,
stripe: [
'invoice' => $invoiceId ?: null,
'subscription' => $subscription->stripe_subscription_id,
],
extra: ['attempt' => $invoice['attempt_count'] ?? null],
chargedGrossCents: 0,
// Per attempt, not per invoice: Stripe retries a failing
// payment on a schedule, and each attempt is its own event.
eventKey: $invoiceId !== ''
? 'payment_failed:'.$invoiceId.':'.($invoice['attempt_count'] ?? 0)
: null,
)
);
}
/**
* Stripe's own status changed. Theirs is the authority on whether the money
* is arriving; we copy it, and leave what the customer may USE to our own
* status, which only a cancellation or an operator changes.
*
* @param array<string, mixed> $object
*/
public function subscriptionUpdated(array $object, ?Carbon $eventAt = null): ?Subscription
{
$subscription = $this->resolve($object['id'] ?? null);
if ($subscription === null) {
return null;
}
[$start, $end] = $this->period($object);
// Ended is ended, whatever order events arrive in — and an older
// snapshot must never overwrite a newer one it was delivered behind.
$this->mutateInOrder($subscription, $eventAt, self::RANK_UPDATED, fn () => array_filter([
'stripe_status' => $object['status'] ?? null,
'current_period_start' => $start,
'current_period_end' => $end,
], fn ($value) => $value !== null));
return $subscription;
}
/**
* The subscription has ended at Stripe. That is the end of the contract,
* so it goes in the register a cancellation nobody recorded is exactly
* the kind of gap the register exists to prevent.
*
* @param array<string, mixed> $object
*/
public function subscriptionDeleted(array $object, ?Carbon $eventAt = null): ?SubscriptionRecord
{
$subscription = $this->resolve($object['id'] ?? null);
if ($subscription === null) {
return null;
}
$endedAt = isset($object['ended_at'])
? Carbon::createFromTimestamp((int) $object['ended_at'])
: now();
// The ending and its entry in the register commit together. Marking the
// contract cancelled first and failing before the record would leave a
// retry seeing "already cancelled" and returning — and the end of a
// contract would never be entered at all.
return $this->recordOnce(fn () => DB::transaction(function () use ($subscription, $object, $endedAt, $eventAt) {
// Held while we decide, so a renewal arriving at the same moment
// cannot reactivate the contract between this and the write.
$subscription = Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->firstOrFail();
// No ordering guard: an ending is final, so a late delivery of it
// is still correct. Only the running picture can go stale.
$subscription->update([
'status' => 'cancelled',
'stripe_status' => $object['status'] ?? 'canceled',
'cancelled_at' => $endedAt,
'stripe_event_at' => $eventAt,
]);
return ($this->record)(
event: SubscriptionRecord::EVENT_CANCELLATION,
subscription: $subscription->refresh(),
netCents: 0,
at: $endedAt,
stripe: ['subscription' => $subscription->stripe_subscription_id],
chargedGrossCents: 0,
// A contract ends once. Two deliveries arriving together would
// both pass a status check; only one can take this key.
eventKey: 'cancellation:'.$subscription->id,
);
}));
}
/**
* Whether this event is the newest word we have about the contract.
*
* Stripe does not guarantee delivery order, and the state here is a
* running picture rather than a log: applying a stale snapshot on top of a
* fresher one is how a paid-up customer ends up looking overdue. The
* register is unaffected each entry is keyed by what it is about, so a
* late arrival still lands once, in its proper place.
*/
/**
* Decide and write in one step, with the contract's row held.
*
* The ordering guard is only worth as much as its atomicity: two
* deliveries for the same contract can both read a stale copy, both pass
* the check, and the loser then overwrites the winner a renewal
* reactivating a contract a deletion had just ended, or an old failure
* burying a payment that went through. So the row is re-read under a lock
* and re-judged inside the transaction that writes it.
*
* `$changes` returns the columns to set, or null to decline.
*
* @param callable(Subscription): ?array<string, mixed> $changes
*/
private function mutateInOrder(Subscription $subscription, ?Carbon $eventAt, int $rank, callable $changes): void
{
DB::transaction(function () use ($subscription, $eventAt, $rank, $changes) {
$fresh = Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->first();
if ($fresh === null
|| $fresh->status === 'cancelled'
|| ! $this->appliesInOrder($fresh, $eventAt, $rank)) {
return;
}
$values = $changes($fresh);
if ($values === null) {
return;
}
if ($eventAt !== null) {
$values['stripe_event_at'] = $eventAt;
$values['stripe_event_rank'] = $rank;
}
$fresh->update($values);
});
$subscription->refresh();
}
private function appliesInOrder(Subscription $subscription, ?Carbon $eventAt, int $rank): bool
{
if ($eventAt === null || $subscription->stripe_event_at === null) {
return true; // nothing to compare against
}
if ($eventAt->greaterThan($subscription->stripe_event_at)) {
return true;
}
if ($eventAt->lessThan($subscription->stripe_event_at)) {
return false;
}
// Same second, which Stripe's one-second timestamps make common enough
// to matter: a failed attempt and the retry that succeeded can share
// one. Decide by what the event says about the money.
return $rank >= (int) ($subscription->stripe_event_rank ?? 0);
}
/**
* Write a register entry, unless one already exists for this invoice and
* event.
*
* The uniqueness lives in the database, on (stripe_invoice_id, event).
* Checking first and inserting afterwards leaves a window that Stripe
* delivering the same invoice twice at once walks straight through and a
* register that counts a payment twice is worse than one that is late.
*
* @param callable(): SubscriptionRecord $write
*/
private function recordOnce(callable $write): ?SubscriptionRecord
{
try {
return $write();
} catch (UniqueConstraintViolationException) {
return null; // already recorded by a concurrent delivery
}
}
/**
* Hold an event whose contract does not exist yet, so it is not lost.
*
* @param array<string, mixed> $event
*/
public function hold(array $event): void
{
$subscriptionId = $event['data']['object']['subscription']
?? $event['data']['object']['id']
?? null;
if (! is_string($subscriptionId) || ! is_string($event['id'] ?? null)) {
return;
}
// Only what we could not match. A handler returns null for several
// reasons — already recorded, or deliberately skipped, like every
// checkout's own invoice — and holding those would fill the table with
// rows that replayHeldFor() can never revisit, because their contract
// is right there. Nothing to wait for means nothing to hold.
if (Subscription::query()->where('stripe_subscription_id', $subscriptionId)->exists()) {
// Unless it appeared just now: the contract can be created between
// the handler missing it and this check, and the replay that would
// have collected it has then already been and gone. Apply it here
// instead of dropping it. Safe to repeat — the handlers are
// idempotent, so an event that was a no-op stays one.
$this->dispatch($event);
return;
}
StripePendingEvent::query()->updateOrCreate(
['stripe_event_id' => $event['id']],
[
'stripe_subscription_id' => $subscriptionId,
'type' => (string) ($event['type'] ?? ''),
'raised_at' => isset($event['created']) && is_numeric($event['created'])
? Carbon::createFromTimestamp((int) $event['created'])
: null,
'payload' => $event,
],
);
}
/**
* Replay everything held for a contract that has just appeared.
*
* In their original order, so the ordering guard sees them as Stripe raised
* them rather than as they happened to be stored.
*/
public function replayHeldFor(Subscription $subscription): int
{
if ($subscription->stripe_subscription_id === null) {
return 0;
}
$held = StripePendingEvent::query()
->where('stripe_subscription_id', $subscription->stripe_subscription_id)
->orderBy('raised_at')
->orderBy('id')
->get();
foreach ($held as $pending) {
$this->dispatch($pending->payload ?? []);
$pending->delete();
}
return $held->count();
}
/**
* Route one event to its handler. Returns false for a type we do not
* handle, so the caller can tell "not ours" from "nothing to do".
*
* @param array<string, mixed> $event
*/
public function dispatch(array $event): mixed
{
$object = $event['data']['object'] ?? [];
$raisedAt = isset($event['created']) && is_numeric($event['created'])
? Carbon::createFromTimestamp((int) $event['created'])
: null;
return match ($event['type'] ?? '') {
'invoice.paid' => $this->invoicePaid($object, $raisedAt),
'invoice.payment_failed' => $this->invoicePaymentFailed($object, $raisedAt),
'customer.subscription.updated' => $this->subscriptionUpdated($object, $raisedAt),
'customer.subscription.deleted' => $this->subscriptionDeleted($object, $raisedAt),
default => false,
};
}
/**
* Find the contract a Stripe event is about.
*
* An unknown id is logged and dropped rather than raised: Stripe delivers
* events for objects created by hand in the dashboard, and by other
* environments pointed at the same endpoint. Failing the webhook for those
* would have Stripe retry something that can never succeed.
*/
private function resolve(mixed $stripeSubscriptionId): ?Subscription
{
$id = is_string($stripeSubscriptionId) ? $stripeSubscriptionId : null;
if ($id === null) {
return null;
}
return Subscription::query()->where('stripe_subscription_id', $id)->first();
}
/**
* @param array<string, mixed> $object
* @return array{0: ?Carbon, 1: ?Carbon}
*/
private function period(array $object): array
{
$start = $object['period_start'] ?? $object['current_period_start'] ?? null;
$end = $object['period_end'] ?? $object['current_period_end'] ?? null;
return [
is_numeric($start) ? Carbon::createFromTimestamp((int) $start) : null,
is_numeric($end) ? Carbon::createFromTimestamp((int) $end) : null,
];
}
}

View File

@ -1,132 +0,0 @@
<?php
namespace App\Actions;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\SubscriptionRecord;
use App\Services\Billing\AddonCatalogue;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Books a module onto a contract at today's price, and freezes it there.
*
* Booking is the moment the module's price stops moving for this customer, for
* exactly the reason the plan's price does: they agreed to a figure. What they
* have NOT booked stays on the live catalogue that is a sale still to be
* made, at whatever it costs now.
*/
class BookAddon
{
public function __construct(private RecordCommercialEvent $record) {}
public function __invoke(Subscription $subscription, string $addonKey, int $quantity = 1, ?Order $order = null): SubscriptionAddon
{
$price = app(AddonCatalogue::class)->priceCents($addonKey);
if ($price === null) {
throw new RuntimeException("Unknown add-on: {$addonKey}");
}
if ($quantity < 1) {
throw new RuntimeException('An add-on is booked at least once.');
}
try {
return $this->book($subscription, $addonKey, $quantity, $order, $price);
} catch (UniqueConstraintViolationException) {
// A concurrent retry won. The unique index on (order_id, addon_key)
// is what actually enforces "one order books one module" — the
// lookup below is only the fast path, and two transactions can both
// pass it.
return SubscriptionAddon::query()
->where('order_id', $order?->id)
->where('addon_key', $addonKey)
->firstOrFail();
}
}
private function book(Subscription $subscription, string $addonKey, int $quantity, ?Order $order, int $price): SubscriptionAddon
{
// The booking and its entry in the register commit together: if the
// record could fail afterwards, retrying the same order would find the
// add-on already there and skip the event for good.
return DB::transaction(function () use ($subscription, $addonKey, $quantity, $order, $price) {
// Idempotent against a retried webhook: one order books one module.
if ($order !== null) {
$existing = SubscriptionAddon::query()
->where('order_id', $order->id)
->where('addon_key', $addonKey)
->first();
if ($existing !== null) {
return $existing;
}
}
$addon = SubscriptionAddon::create([
'subscription_id' => $subscription->id,
'order_id' => $order?->id,
'addon_key' => $addonKey,
'price_cents' => $price,
'currency' => Subscription::catalogueCurrency(),
'quantity' => $quantity,
'booked_at' => now(),
]);
($this->record)(
event: SubscriptionRecord::EVENT_ADDON_BOOKED,
subscription: $subscription,
netCents: $addon->monthlyCents(),
extra: ['addon' => ['key' => $addonKey, 'quantity' => $quantity, 'price_cents' => $price]],
order: $order,
stripe: ['event' => $order?->stripe_event_id],
// What was actually charged for the module, on the same terms
// as a plan purchase: a discount or a free booking has to be
// reconcilable, not reconstructed from the catalogue.
chargedGrossCents: $order?->stripe_event_id !== null ? (int) $order->amount_cents : null,
);
return $addon;
});
}
/**
* Stop charging for a module, without losing what it cost.
*
* Cancelled, not deleted: what a customer was paying, and until when, is
* part of the same record as what they bought.
*/
public function cancel(SubscriptionAddon $addon): SubscriptionAddon
{
return DB::transaction(function () use ($addon) {
// Claim the cancellation conditionally, so two requests arriving
// together write one event between them. Checking `isActive()` on
// separate instances and updating afterwards lets both through, and
// the register would show a module cancelled twice.
$claimed = SubscriptionAddon::query()
->whereKey($addon->getKey())
->whereNull('cancelled_at')
->update(['cancelled_at' => now(), 'updated_at' => now()]);
if ($claimed === 0) {
return $addon->refresh();
}
$addon->refresh();
($this->record)(
event: SubscriptionRecord::EVENT_ADDON_CANCELLED,
subscription: $addon->subscription,
netCents: -$addon->monthlyCents(),
extra: ['addon' => ['key' => $addon->addon_key, 'quantity' => $addon->quantity]],
order: $addon->order,
);
return $addon;
});
}
}

View File

@ -1,73 +0,0 @@
<?php
namespace App\Actions\Fortify;
use App\Models\Customer;
use App\Models\Operator;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique(User::class),
// Never let public signup claim an existing (possibly not-yet-
// provisioned) customer's email — ensureUser() would later link
// that account and hand over the customer's portal.
Rule::unique(Customer::class, 'email'),
// Nor an operator's — R21: one address must not represent both
// identities. No dedicated message: Laravel's stock "already
// taken" line (translated in both languages already) is what
// the two rules above fall back to as well, and telling a
// public visitor specifically that an address belongs to
// STAFF would be a worse leak than the generic refusal every
// other collision here already gives them.
Rule::unique(Operator::class, 'email'),
],
'password' => $this->passwordRules(),
])->validate();
// Create the user and its linked customer atomically — a public signup is
// a customer, and the portal (Billing::purchase, Settings, …) needs one.
// Either both exist or neither, so a failure never orphans a user.
return DB::transaction(function () use ($input) {
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
Customer::query()->create([
'user_id' => $user->id,
'name' => $input['name'],
'email' => $input['email'],
'locale' => app()->getLocale(),
'status' => 'active',
]);
return $user;
});
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Actions\Fortify;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
}

View File

@ -1,32 +0,0 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}

View File

@ -1,35 +0,0 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
class UpdateUserPassword implements UpdatesUserPasswords
{
use PasswordValidationRules;
/**
* Validate and update the user's password.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'current_password' => ['required', 'string', 'current_password:web'],
'password' => $this->passwordRules(),
], [
'current_password.current_password' => __('The provided password does not match your current password.'),
])->validateWithBag('updatePassword');
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}

View File

@ -1,61 +0,0 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
/**
* Validate and update the given user's profile information.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users')->ignore($user->id),
],
])->validateWithBag('updateProfileInformation');
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
$this->updateVerifiedUser($user, $input);
} else {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
])->save();
}
}
/**
* Update the given verified user's profile information.
*
* @param array<string, string> $input
*/
protected function updateVerifiedUser(User $user, array $input): void
{
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'email_verified_at' => null,
])->save();
$user->sendEmailVerificationNotification();
}
}

View File

@ -1,96 +0,0 @@
<?php
namespace App\Actions;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Opens the contract a paid order bought.
*
* This is the moment the catalogue stops applying. Everything the customer is
* owed price, quotas, seats, the hardware behind the plan is copied onto
* the subscription here and never read from the catalogue again. Provisioning
* sizes the machine from this row, so an operator editing a plan afterwards
* cannot reach a customer who has already paid.
*
* `price_cents` is the catalogue's NET price, which is what PlanChange prorates
* against deliberately not `Order::amount_cents`, which holds the GROSS total
* Stripe actually charged. The two can legitimately differ (VAT, and later
* coupons), and reconciling them is not this action's job: the proof register
* records what was charged per event, and Stripe's invoice is the authority for
* the amount. Copying a gross total into this net field would silently corrupt
* every pro-rata calculation that reads it.
*/
class OpenSubscription
{
public function __construct(private RecordCommercialEvent $record) {}
public function __invoke(Order $order, string $term = Subscription::TERM_MONTHLY): Subscription
{
// A retried webhook must not open a second contract for one purchase.
$existing = Subscription::query()->where('order_id', $order->id)->first();
if ($existing !== null) {
return $existing;
}
$start = now();
// The contract and its entry in the register commit together. If the
// record could fail after the subscription is in, the next webhook
// retry would find the contract, return early, and leave a paid sale
// permanently missing from the evidence.
return DB::transaction(fn () => $this->open($order, $term, $start));
}
private function open(Order $order, string $term, Carbon $start): Subscription
{
$subscription = Subscription::create(array_merge(
// The version the order carries, when the checkout recorded one:
// what the customer saw beats what happens to be on sale by the
// time their payment reaches us.
Subscription::snapshotFrom($order->plan, $term, $order->plan_version_id),
[
'customer_id' => $order->customer_id,
'order_id' => $order->id,
// Carried from the checkout: from here on, Stripe's events
// name this and nothing else.
'stripe_subscription_id' => $order->stripe_subscription_id,
'started_at' => $start,
'current_period_start' => $start,
'current_period_end' => $term === Subscription::TERM_YEARLY
? $start->copy()->addYear()
: $start->copy()->addMonth(),
'status' => 'active',
],
));
// The sale, entered in the register the moment it happens. Written from
// the contract that was just frozen, so the evidence and the contract
// cannot describe different purchases.
($this->record)(
event: SubscriptionRecord::EVENT_PURCHASE,
subscription: $subscription,
netCents: $subscription->price_cents,
at: $start,
stripe: ['event' => $order->stripe_event_id],
// The order carries what Stripe actually took — gross, including
// whatever tax or discount applied on the day. The contract price
// is what was agreed; this is what was paid, and the register has
// to be able to state both.
//
// Keyed on the Stripe id, not on the amount being non-zero: a fully
// discounted checkout charges zero, and reading that as "no amount
// recorded" would file a free sale as though it had been paid for
// in full. An order without a Stripe id was never charged through
// a checkout at all, and has nothing to report.
chargedGrossCents: $order->stripe_event_id !== null ? (int) $order->amount_cents : null,
);
return $subscription;
}
}

View File

@ -1,146 +0,0 @@
<?php
namespace App\Actions;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Services\Billing\TaxTreatment;
use Illuminate\Support\Carbon;
/**
* Writes one row into the proof register.
*
* Every commercial event goes through here so that the flat columns are filled
* the same way each time an evidence table where the amount means one thing
* in one row and another in the next is not evidence.
*
* Amounts are stated three ways on purpose. Net is what the catalogue and the
* contract deal in; gross is what was actually charged; the tax between them
* depends on the customer and on the day, and recomputing it later from a rate
* that has since changed would produce a different answer to the one on the
* invoice.
*/
class RecordCommercialEvent
{
/**
* @param array<string, mixed> $extra merged into the JSON snapshot
* @param array<string, string|null> $stripe event / invoice / subscription ids
*/
public function __invoke(
string $event,
Subscription $subscription,
int $netCents,
?Carbon $at = null,
array $extra = [],
array $stripe = [],
?int $chargedGrossCents = null,
?Order $order = null,
?string $eventKey = null,
): SubscriptionRecord {
$at ??= now();
$customer = $subscription->customer;
$tax = TaxTreatment::for($customer);
$agreedNet = $netCents;
$expectedGross = $tax->grossCents($agreedNet);
// The flat columns describe the TRANSACTION, not the contract: gross is
// what was taken from the customer, and net and tax are that gross
// split by the rate that applied on the day.
//
// Split, not subtracted from the agreed price. A discount lowers the
// taxable amount; it does not create negative VAT, and recording
// 14900 charged against a 179,00 contract as "minus 30,00 tax" would
// make the register wrong in exactly the case someone audits.
if ($chargedGrossCents !== null) {
$gross = $chargedGrossCents;
$net = (int) round($gross / (1 + $tax->rate));
} else {
$net = $agreedNet;
$gross = $expectedGross;
}
$version = $subscription->planVersion;
return SubscriptionRecord::create([
'event' => $event,
// What makes this event "the same one" if it arrives again. Unique
// where set, so a duplicate delivery collides in the database
// rather than in a check that two of them can both pass.
'event_key' => $eventKey,
'customer_id' => $subscription->customer_id,
'subscription_id' => $subscription->id,
// The order this event belongs to — a booked module has its own,
// and pointing it at the original plan purchase would file every
// add-on under the wrong transaction.
'order_id' => $order?->id ?? $subscription->order_id,
'plan_version_id' => $subscription->plan_version_id,
// Copied rather than joined: these have to still answer the question
// after the customer, the plan or the version have gone.
'customer_name' => $customer?->name,
'plan_key' => $subscription->plan,
'plan_version' => $version?->version,
'term' => $subscription->term,
'net_cents' => $net,
'tax_cents' => $gross - $net,
'gross_cents' => $gross,
'currency' => $subscription->currency,
'tax_rate' => round($tax->rate * 100, 2),
'reverse_charge' => $tax->reverseCharge,
'stripe_event_id' => $stripe['event'] ?? null,
'stripe_invoice_id' => $stripe['invoice'] ?? null,
'stripe_subscription_id' => $stripe['subscription'] ?? null,
'occurred_at' => $at,
'snapshot_version' => SubscriptionRecord::SNAPSHOT_VERSION,
// The long tail: everything nobody has thought to ask about yet.
// The flat columns above are what gets queried.
'snapshot' => array_merge([
'subscription' => $subscription->only(Subscription::FROZEN),
'plan' => [
'key' => $subscription->plan,
'version' => $version?->version,
'version_id' => $subscription->plan_version_id,
'family' => $version?->family?->name,
'capabilities' => $version?->capabilities(),
],
'period' => [
'start' => $subscription->current_period_start?->toIso8601String(),
'end' => $subscription->current_period_end?->toIso8601String(),
],
'addons' => $subscription->addons()->active()->get()
->map(fn ($addon) => [
'key' => $addon->addon_key,
'price_cents' => $addon->price_cents,
'quantity' => $addon->quantity,
'booked_at' => $addon->booked_at?->toIso8601String(),
])->all(),
'customer' => [
'name' => $customer?->name,
'email' => $customer?->email,
'vat_id' => $customer?->vat_id,
],
// Kept even when they agree. A charge that differs from the
// catalogue plus tax is exactly the thing someone will ask
// about later, and reconciling it silently would erase the
// question along with the answer.
'amounts' => [
// What was agreed, beside what was actually taken. The
// flat columns state the transaction; this states whether
// it came out at the contract price, which is the question
// someone asks a year later.
'agreed_net_cents' => $agreedNet,
'expected_gross_cents' => $expectedGross,
'charged_gross_cents' => $gross,
'matches_catalogue' => $gross === $expectedGross,
],
], $extra),
]);
}
}

View File

@ -1,311 +0,0 @@
<?php
namespace App\Actions;
use App\Mail\InvoiceMail;
use App\Mail\OrderConfirmationMail;
use App\Services\Billing\IssueInvoice;
use App\Exceptions\IdentityCollisionException;
use App\Models\Customer;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* Turns a paid Stripe event into a customer + order + provisioning run. The
* order's unique stripe_event_id is the idempotency key: a duplicate webhook
* never starts a second run.
*/
class StartCustomerProvisioning
{
public function __construct(
private OpenSubscription $openSubscription,
private ApplyStripeBillingEvent $billing,
) {}
/**
* @param array{id:string,email:string,name:?string,stripe_customer_id:?string,plan:string,datacenter:string,amount_cents:int,currency:string} $event
*/
public function fromStripeEvent(array $event): ?Order
{
$existing = Order::query()->where('stripe_event_id', $event['id'])->first();
if ($existing !== null) {
$this->resume($existing);
return null;
}
$customer = $this->resolveCustomer($event);
try {
$customer->ensureUser(); // portal login (also enables admin impersonation)
} catch (IdentityCollisionException) {
// Same principle as openContract() below: the order is the record
// that money changed hands, so a paid order still gets recorded
// and provisioned. Only the colliding portal LOGIN is withheld —
// R21 — until an operator resolves the address conflict by hand
// (rename the operator, or the customer). Until then this
// customer has no self-service sign-in and cannot be
// impersonated either; both call ensureUser() the same way.
Log::error('Portal login withheld: this customer email already belongs to an operator account.', [
'customer_id' => $customer->id, 'email' => $customer->email,
]);
}
try {
[$order, $run] = DB::transaction(function () use ($event, $customer) {
$order = Order::create([
'customer_id' => $customer->id,
'plan' => $event['plan'],
// Set once Stripe checkout carries it (phase 5). Null means
// "whatever is on sale when the webhook lands", which is
// what this did before the column existed.
'plan_version_id' => $event['plan_version_id'] ?? null,
'amount_cents' => $event['amount_cents'],
'currency' => $event['currency'],
'datacenter' => $event['datacenter'],
'stripe_event_id' => $event['id'],
'stripe_subscription_id' => $event['stripe_subscription_id'] ?? null,
'status' => 'paid',
]);
$run = ProvisioningRun::create([
'subject_type' => Order::class,
'subject_id' => $order->id,
'pipeline' => 'customer',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
// Snapshot resolved branding so retries apply identical inputs
// (unset customer → CluPilot defaults, resolved once here).
'context' => ['branding' => $customer->brandingResolved()],
]);
return [$order, $run];
});
} catch (UniqueConstraintViolationException) {
// A concurrent delivery won the race. Both requests will answer
// Stripe with a 2xx, so this is the last look anyone takes at this
// payment — if the winner then dies before opening the contract, no
// retry is coming to fix it. So finish its work rather than just
// stepping aside. The unique index on subscriptions.order_id keeps
// both from opening one.
$winner = Order::query()->where('stripe_event_id', $event['id'])->first();
if ($winner !== null) {
$this->resume($winner);
}
return null;
}
$this->openContract($order);
AdvanceRunJob::dispatch($run->uuid); // after commit
$this->confirmByMail($order, $customer);
return $order;
}
/**
* Invoice the payment, and tell the customer once.
*
* ONE mail, not two. The invoice is issued at the moment the money lands,
* so an order confirmation and an invoice mail would otherwise go out in
* the same second for the same event which is a defect, not a preference.
* The invoice mail carries the document and says everything the
* confirmation said, so it replaces it whenever there is an invoice; the
* confirmation remains for the case where there cannot be one.
*
* Sent here rather than when provisioning finishes: those are minutes apart
* at best and can be much longer, and somebody who has just been charged
* and heard nothing assumes the worst about both the charge and the
* product. CloudReady announces the end; this announces the beginning.
*
* After the transaction, never inside it: a queued job can be picked up by
* a worker before the commit lands, and it would then read an order that
* does not exist yet.
*
* Never allowed to fail the webhook. Stripe retries anything that is not a
* 2xx, and a retry here would re-enter provisioning a second machine
* built over a mail server or a missing VAT number.
*/
private function confirmByMail(Order $order, Customer $customer): void
{
$address = $customer->email;
if (! is_string($address) || $address === '') {
return;
}
$invoice = null;
try {
// Refuses while the company details are incomplete, which is the
// correct outcome — an invoice without a registered name or a VAT
// number is not a valid invoice, and issuing one consumes a number
// that can never be handed out again. The purchase is unaffected:
// the order stands, the machine gets built, and the invoice can be
// issued from the console once the details are there.
$invoice = app(IssueInvoice::class)->forOrders($customer, collect([$order]));
} catch (Throwable $e) {
Log::warning('Could not issue an invoice for this order', [
'order' => $order->id,
'exception' => $e->getMessage(),
]);
}
try {
Mail::to($address)->queue($invoice !== null
? new InvoiceMail($invoice, (string) $customer->name)
: new OrderConfirmationMail($order, (string) $customer->name));
} catch (Throwable $e) {
Log::warning('Could not queue the purchase confirmation', [
'order' => $order->id,
'exception' => $e->getMessage(),
]);
}
}
/**
* Finish what a crash interrupted.
*
* The order commits before the contract is opened, so that a failure there
* can never erase the record of a payment but that leaves a window in
* which a paid order exists with no contract and nothing running. Stripe
* retries until it gets a 2xx, so a retry is exactly the chance to close
* that window. Simply returning "already processed" would strand a paying
* customer permanently, because no later webhook would ever look again.
*
* Safe to run on every duplicate: opening a contract is idempotent. A run
* that was merely never dispatched needs no nudge from here the scheduler
* tick sweeps pending runs. A run that already ran and FAILED for want of
* the contract does, because nothing sweeps failed runs.
*/
private function resume(Order $order): void
{
if ($order->subscription()->exists()) {
return;
}
$this->openContract($order);
if ($order->subscription()->exists()) {
$this->reviveRunStrandedWithoutAContract($order);
}
}
/**
* Restart a run that failed only because the contract was missing.
*
* Narrow on purpose. `no_subscription` is returned before anything is built
* the run never got past validating the order or reserving resources so
* there is nothing half-made to trip over. A run that failed for any other
* reason failed at something a contract does not fix, and restarting it
* would just repeat whatever went wrong.
*/
private function reviveRunStrandedWithoutAContract(Order $order): void
{
$run = $order->runs()->where('pipeline', 'customer')->latest('id')->first();
if ($run?->status !== ProvisioningRun::STATUS_FAILED
|| ! str_contains((string) $run->error, 'no_subscription')) {
return;
}
// onProvisioningFailed() marked the order failed on the way down.
$order->update(['status' => 'paid']);
$run->update([
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
'attempt' => 0,
'next_attempt_at' => null,
'error' => null,
]);
AdvanceRunJob::dispatch($run->uuid);
Log::info('Restarted a run that was stranded without a contract.', [
'order_id' => $order->id, 'run' => $run->uuid,
]);
}
/**
* Freeze what they bought, while the catalogue still says what they were
* shown.
*
* Deliberately AFTER the order is committed, and deliberately swallowing
* its failure. The order is the record that money changed hands: if opening
* the contract could roll it back, an unsellable plan, a missing price or
* any unforeseen fault would erase the evidence of a payment we have
* already taken and Stripe, seeing no 2xx, would retry a webhook that can
* never succeed. A missing contract is recoverable and loud: the run stops
* at ValidateOrder with `no_subscription`, in the operator's face.
*
* Two cases are expected to land here: a plan the catalogue cannot sell,
* and a payment in a currency it cannot price freezing a EUR price onto a
* CHF payment would put the contract and the payment in permanent
* disagreement.
*/
private function openContract(Order $order): void
{
try {
// A checkout that recorded its version is owed THAT version, even
// if the window has closed or the plan has been withdrawn since —
// they paid for what they were shown. Only a purchase with no
// recorded version has to ask what is on sale now.
$deliverable = $order->plan_version_id !== null
? app(PlanCatalogue::class)->isDeliverable($order->plan, $order->plan_version_id)
: Subscription::knowsPlan($order->plan);
$sellable = $deliverable
&& strtoupper((string) $order->currency) === Subscription::catalogueCurrency();
if (! $sellable) {
Log::warning('No contract opened: the catalogue cannot sell this order.', [
'order_id' => $order->id, 'plan' => $order->plan, 'currency' => $order->currency,
]);
return;
}
$subscription = ($this->openSubscription)($order);
// Anything Stripe sent about this contract before it existed —
// a renewal, a failure, a cancellation — applied now, in the order
// they were raised.
$this->billing->replayHeldFor($subscription);
} catch (Throwable $e) {
Log::error('Failed to open a contract for a paid order.', [
'order_id' => $order->id, 'plan' => $order->plan, 'error' => $e->getMessage(),
]);
}
}
/**
* Find or create the customer, race-safe against the unique email index so a
* concurrent first-time purchase can't create two customers for one email.
*
* @param array{email:string,name:?string,stripe_customer_id:?string} $event
*/
private function resolveCustomer(array $event): Customer
{
try {
return Customer::query()->firstOrCreate(
['email' => $event['email']],
['name' => $event['name'] ?? $event['email'], 'stripe_customer_id' => $event['stripe_customer_id'] ?? null],
);
} catch (UniqueConstraintViolationException) {
return Customer::query()->where('email', $event['email'])->firstOrFail();
}
}
}

View File

@ -1,49 +0,0 @@
<?php
namespace App\Actions;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
/**
* Registers a fresh host and kicks off its onboarding run. The one-time root
* password lives encrypted in the run context and is scrubbed after SSH trust
* is established.
*/
class StartHostOnboarding
{
/**
* @param array{name: string, datacenter: string, public_ip: string, root_password: string} $input
*/
public function run(array $input): Host
{
// Host + run are created atomically; a partial insert would otherwise
// leave a permanently pending host with no run.
[$host, $run] = DB::transaction(function () use ($input) {
$host = Host::create([
'name' => $input['name'],
'datacenter' => $input['datacenter'],
'public_ip' => $input['public_ip'],
'status' => 'pending',
]);
$run = ProvisioningRun::create([
'subject_type' => Host::class,
'subject_id' => $host->id,
'pipeline' => 'host',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
'context' => ['root_password' => Crypt::encryptString($input['root_password'])],
]);
return [$host, $run];
});
AdvanceRunJob::dispatch($run->uuid); // after commit — the run definitely exists
return $host;
}
}

View File

@ -1,63 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Jobs\ArchiveInvoice;
use App\Models\Invoice;
use App\Models\ExportTarget;
use Illuminate\Console\Command;
/**
* The sweep behind the copy-on-issue.
*
* Copying at issue is right and it is not enough. An invoice issued while the
* office connection was down never reaches the archive, the queue eventually
* gives up, and nothing about the invoice tells anybody nobody opens an
* archive to check whether last Tuesday is in it. This picks up whatever is
* still missing, however long ago it was issued.
*
* Deliberately re-queues rather than writing here: the job already knows how to
* write one, and two code paths that both write an invoice into an archive is
* one more than the number that can be kept correct.
*/
class ArchiveUnexportedInvoices extends Command
{
protected $signature = 'clupilot:archive-invoices {--limit=200}';
protected $description = 'Queue a copy of every invoice that is not on the archive yet';
public function handle(): int
{
$targets = ExportTarget::query()->where('active', true)->get();
if ($targets->isEmpty()) {
$this->line('No export target configured — nothing to do.');
return self::SUCCESS;
}
$queued = 0;
foreach ($targets as $target) {
// Per destination, because "arrived" is a fact about a pair. An
// invoice on the office NAS and not on the off-site box is exactly
// the case this exists to catch.
$pending = Invoice::query()
->whereDoesntHave('exports', fn ($q) => $q
->where('export_target_id', $target->id)
->whereNotNull('exported_at'))
->orderBy('id')
->limit((int) $this->option('limit'))
->get();
foreach ($pending as $invoice) {
ArchiveInvoice::dispatch($invoice, $target);
$queued++;
}
}
$this->line("Queued {$queued} copy/copies for {$targets->count()} destination(s).");
return self::SUCCESS;
}
}

View File

@ -1,99 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
use Illuminate\Console\Command;
/**
* Tells the owner whether the catalogue is in a state that can actually sell.
*
* Everything here is computed at read time, which means a broken window or a
* missing price does not announce itself until a customer hits it. This is the
* thing to run after editing plans before finding out from a failed checkout.
*/
class CheckPlanCatalogue extends Command
{
protected $signature = 'plans:check';
protected $description = 'Check the plan catalogue for overlaps, gaps and missing prices';
public function handle(): int
{
$currency = Subscription::catalogueCurrency();
$problems = [];
$now = now();
$families = PlanFamily::query()->with('versions.prices')->orderBy('tier')->get();
if ($families->isEmpty()) {
$this->error('The catalogue is empty. Nothing can be sold.');
return self::FAILURE;
}
foreach ($families as $family) {
$live = $family->versions->filter(fn (PlanVersion $v) => $v->isAvailableAt($now));
if ($live->count() > 1) {
$problems[] = "{$family->key}: {$live->count()} versions on sale at once (".
$live->pluck('version')->implode(', ').') — overlapping windows.';
}
if ($family->sales_enabled && $live->isEmpty()) {
$problems[] = "{$family->key}: on sale, but no version is available right now.";
}
foreach ($family->versions as $version) {
if (! $version->isPublished()) {
continue;
}
foreach ([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY] as $term) {
$priced = $version->prices
->first(fn ($p) => $p->term === $term && $p->currency === $currency);
if ($priced === null) {
$problems[] = "{$family->key} v{$version->version}: no {$term} price in {$currency}.";
}
}
}
}
// A contract that cannot say which version it was sold under has lost
// its provenance, which is the one thing the version table is for.
$orphaned = Subscription::query()->whereNull('plan_version_id')->count();
if ($orphaned > 0) {
$problems[] = "{$orphaned} subscription(s) have no plan version recorded.";
}
foreach ($families as $family) {
$state = $family->sales_enabled ? 'on sale' : 'withdrawn';
$version = $family->versions->first(fn (PlanVersion $v) => $v->isAvailableAt($now));
$this->line(sprintf(
' %-12s tier %d %-10s %s',
$family->key,
$family->tier,
$state,
$version !== null ? "v{$version->version}" : '—',
));
}
if ($problems === []) {
$this->newLine();
$this->info('Catalogue is consistent.');
return self::SUCCESS;
}
$this->newLine();
foreach ($problems as $problem) {
$this->error(' '.$problem);
}
return self::FAILURE;
}
}

View File

@ -1,145 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Http\Middleware\RestrictConsoleNetwork;
use App\Support\Settings;
use Illuminate\Console\Command;
/**
* The way back in when the console has locked its owner out.
*
* The allowlist is managed in the console which is fine until the address you
* manage it from changes, and then the page that would fix the problem is the
* page the problem blocks. Every gate needs a door that does not depend on
* itself, and on a server that door is a shell.
*/
class ConsoleAccess extends Command
{
protected $signature = 'clupilot:console-access
{action=show : show|allow|deny|open|close|caddy}
{value? : an address or CIDR, for allow and deny}';
protected $description = 'Show or change who may reach the operator console';
public function handle(): int
{
$action = (string) $this->argument('action');
$value = trim((string) $this->argument('value'));
return match ($action) {
'show' => $this->show(),
'allow' => $this->allow($value),
'deny' => $this->deny($value),
'open' => $this->setRestricted(false),
'close' => $this->setRestricted(true),
'caddy' => $this->caddy(),
default => $this->refuse("Unknown action: {$action}"),
};
}
/**
* The allowlist as a Caddy matcher, for the reverse proxy to import.
*
* The proxy has its own allowlist, hard-coded, and it runs FIRST so
* everything the owner adds in the console has no effect whatsoever, and
* the console's own access page is a decoration. Worse, when the owner's
* address changes they are turned away by the proxy before the application
* they could have fixed it in is ever reached.
*
* Emitting the matcher from the same list the console manages makes the
* console the single authority. The agent on the host writes this out and
* reloads the proxy.
*/
private function caddy(): int
{
// Always the allowlist — never 0.0.0.0/0, not even when the console's
// own restriction is switched off.
//
// The owner's rule is absolute: the console is reachable over the
// management VPN, or from an address they have listed, and from nowhere
// else. Letting the console's "open" switch also open the PROXY would
// put the console on the public internet with one click, which is
// exactly the state that rule exists to prevent. Switching it off
// relaxes the application's own check; the proxy keeps its list.
$ranges = RestrictConsoleNetwork::allowedRanges();
// Never empty: an empty remote_ip matcher matches NOTHING in Caddy, and
// the console would become unreachable from anywhere at all — including
// from the place someone would fix it. Loopback always survives, so a
// shell on the box is always a way back.
if ($ranges === []) {
$ranges = ['127.0.0.1', '::1'];
}
$this->line('# Generated from the console allowlist — do not edit by hand.');
$this->line('# Regenerated by deploy/update-agent.sh; edit it in the console.');
$this->line('@allowed remote_ip '.implode(' ', $ranges));
return self::SUCCESS;
}
private function show(): int
{
$this->line(' restricted : '.(RestrictConsoleNetwork::isRestricted() ? 'yes' : 'no — anyone reaching the hostname gets in'));
$this->line(' always : '.implode(', ', (array) config('admin_access.trusted_ranges', [])).' (VPN, not removable)');
$own = (array) Settings::get('console.allowed_ips', []);
$this->line(' additional : '.($own === [] ? '(none)' : implode(', ', $own)));
return self::SUCCESS;
}
private function allow(string $value): int
{
if ($value === '') {
return $this->refuse('Give an address or range: clupilot:console-access allow 203.0.113.7');
}
// An entry that matches nothing would be stored, reported as success,
// and leave whoever is recovering still locked out.
if (! RestrictConsoleNetwork::isNetwork($value)) {
return $this->refuse("Not an address or a range: {$value}");
}
$list = (array) Settings::get('console.allowed_ips', []);
if (! in_array($value, $list, true)) {
$list[] = $value;
Settings::set('console.allowed_ips', array_values($list));
}
$this->info("{$value} may now reach the console.");
return $this->show();
}
private function deny(string $value): int
{
Settings::set('console.allowed_ips', array_values(array_filter(
(array) Settings::get('console.allowed_ips', []),
fn ($entry) => $entry !== $value,
)));
$this->info("{$value} removed.");
return $this->show();
}
private function setRestricted(bool $on): int
{
// No lock-out check here on purpose: this command IS the recovery path,
// and it is only reachable by someone who already has the server.
Settings::set('console.network_restricted', $on);
$this->info($on ? 'Console restricted to the VPN and the listed addresses.' : 'Restriction lifted.');
return $this->show();
}
private function refuse(string $message): int
{
$this->error($message);
return self::FAILURE;
}
}

View File

@ -1,84 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Customer;
use App\Models\Operator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
use Spatie\Permission\PermissionRegistrar;
/**
* Creates (or promotes) an operator with the Owner role the account the
* installer sets up so a fresh server is usable immediately.
*
* Promotion is deliberate: running this twice with the same address should fix
* a lost role, not fail with "email already taken".
*
* Renamed from clupilot:create-admin now that the account this creates is an
* Operator, not a User with is_admin set the old name stays reachable as an
* alias so existing install scripts and muscle memory keep working.
*/
class CreateAdmin extends Command
{
protected $signature = 'clupilot:create-operator
{--email= : Login address}
{--name= : Display name}
{--password= : Leave empty to be prompted}';
protected $aliases = ['clupilot:create-admin'];
protected $description = 'Create or promote an operator account with the Owner role';
public function handle(): int
{
$email = $this->option('email') ?: $this->ask('Email');
$name = $this->option('name') ?: $this->ask('Name', 'Administrator');
$password = $this->option('password') ?: $this->secret('Password');
$validator = Validator::make(
['email' => $email, 'name' => $name, 'password' => $password],
[
'email' => 'required|email|max:255',
'name' => 'required|string|max:255',
'password' => ['required', Password::min(12)],
],
);
if ($validator->fails()) {
foreach ($validator->errors()->all() as $error) {
$this->error($error);
}
return self::FAILURE;
}
// An operator address that also belongs to a customer would block that
// customer from ever getting a portal login. Checked directly against
// `users`, not only `customers`: see Customer::emailTaken().
if (Customer::emailTaken($email)) {
$this->error('That address already belongs to a customer.');
return self::FAILURE;
}
$operator = Operator::query()->firstOrNew(['email' => $email]);
$existed = $operator->exists;
$operator->fill([
'name' => $name,
'password' => Hash::make($password),
])->save();
$operator->syncRoles(['Owner']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->info($existed
? "Existing account {$email} promoted to Owner and password reset."
: "Owner account {$email} created.");
return self::SUCCESS;
}
}

View File

@ -1,129 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\ExportTarget;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
/**
* Empty a handover directory of folders nobody needs any more.
*
* Only where a destination asks for it keep_days is null by default, because
* a retention rule nobody configured is a deletion nobody expected.
*
* Safe in a way deleting usually is not: the invoice lives in the database as a
* frozen document and its PDF is rendered from that on demand, so anything
* removed here can be produced again and re-exported. The directory is a
* handover point, not the record.
*
* Only ever removes folders whose NAME is a date it can parse and that are old
* enough. Anything else in there a stray file, a directory somebody made by
* hand, a folder from a different layout is left alone. A prune that deletes
* what it does not recognise is how an archive loses something nobody was
* watching.
*/
class PruneExportFolders extends Command
{
protected $signature = 'clupilot:prune-exports {--dry-run}';
protected $description = 'Remove dated folders older than a destination keeps them';
public function handle(): int
{
$targets = ExportTarget::query()
->whereNotNull('keep_days')
->where('keep_days', '>', 0)
// Only where this machine can see the files. An SFTP destination is
// somebody else's disk with somebody else's retention on it.
->where('driver', ExportTarget::LOCAL)
->get();
foreach ($targets as $target) {
$this->prune($target);
}
return self::SUCCESS;
}
private function prune(ExportTarget $target): void
{
$root = rtrim($target->path, '/');
if (! is_dir($root)) {
// The mount is gone. Saying nothing and doing nothing is right:
// this is a cleanup, and a cleanup must never be the thing that
// reports a broken mount — nor act on whatever is underneath it.
$this->warn("{$target->name}: path is not there, skipping ({$root})");
return;
}
$cutoff = now()->subDays((int) $target->keep_days)->startOfDay();
$removed = 0;
foreach ((array) scandir($root) as $entry) {
if ($entry === '.' || $entry === '..' || ! is_dir($root.'/'.$entry)) {
continue;
}
$date = $this->dateFrom($entry);
// Not a dated folder: left alone, deliberately. A prune that
// deletes what it does not recognise is how an archive loses
// something nobody was watching.
if ($date === null || $date->gte($cutoff)) {
continue;
}
if ($this->option('dry-run')) {
$this->line("would remove {$root}/{$entry}");
$removed++;
continue;
}
$this->removeTree($root.'/'.$entry);
$removed++;
}
$this->line("{$target->name}: {$removed} folder(s) older than {$target->keep_days} days.");
}
/**
* Only the exact shapes this application writes.
*
* Matched with a pattern BEFORE parsing, not by asking Carbon and catching
* what it does with nonsense: under strict mode createFromFormat throws
* rather than returning false, so a folder somebody made by hand would end
* the whole prune instead of being skipped. The pattern also says plainly
* which two shapes are recognised, which is the thing a reader wants to
* know here.
*/
private function dateFrom(string $name): ?Carbon
{
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $name, $m)) {
return Carbon::createFromFormat('Y-m-d', $name)?->startOfDay();
}
if (preg_match('/^\d{4}$/', $name)) {
return Carbon::createFromFormat('Y-m-d', $name.'-12-31')?->startOfDay();
}
return null;
}
private function removeTree(string $directory): void
{
foreach ((array) scandir($directory) as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $directory.'/'.$entry;
is_dir($path) ? $this->removeTree($path) : @unlink($path);
}
@rmdir($directory);
}
}

View File

@ -1,72 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Host;
use App\Services\Dns\HetznerDnsClient;
use Illuminate\Console\Command;
use Throwable;
/**
* Cleans up host DNS records this app published in the PUBLIC Hetzner zone
* before RegisterHostDns moved to the internal vpn-dns hostsdir a host's
* WireGuard address in public DNS hands every scanner the internal subnet.
*
* The ids are whatever is still sitting in hosts.dns_record_id (PurgeHost
* already clears this column for any host removed normally; what remains
* belongs to hosts that are still active).
*
* Never deletes anything on its own: this runs against a live DNS account, a
* migration is the wrong place to make that call unattended, and a stale id
* could by now point at a record this app no longer owns. Without --force
* this only reports what it would remove.
*/
class PruneHostDns extends Command
{
protected $signature = 'clupilot:prune-host-dns {--force : Actually delete the records}';
protected $description = 'List (and, with --force, delete) public Hetzner DNS records this app published for a host';
public function handle(HetznerDnsClient $dns): int
{
$hosts = Host::query()->whereNotNull('dns_record_id')->orderBy('name')->get();
if ($hosts->isEmpty()) {
$this->info('No host carries a public dns_record_id. Nothing to do.');
return self::SUCCESS;
}
$force = (bool) $this->option('force');
$failed = 0;
foreach ($hosts as $host) {
$label = $host->name !== '' && $host->name !== null ? $host->name : $host->uuid;
$recordId = $host->dns_record_id;
if (! $force) {
$this->line(" would delete {$label} record={$recordId}");
continue;
}
try {
$dns->deleteRecord($recordId);
$host->update(['dns_record_id' => null]);
$this->info(" deleted {$label} record={$recordId}");
} catch (Throwable $e) {
$failed++;
$this->error(" failed {$label} record={$recordId}: {$e->getMessage()}");
}
}
if (! $force) {
$this->newLine();
$this->comment(sprintf('Dry run — %d record(s) found, nothing changed. Re-run with --force to delete.', $hosts->count()));
return self::SUCCESS;
}
return $failed === 0 ? self::SUCCESS : self::FAILURE;
}
}

View File

@ -1,135 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\PlanVersion;
use App\Services\Stripe\StripeClient;
use Illuminate\Console\Command;
/**
* Mirrors our catalogue into Stripe: a Product per plan family, a Price per
* priced row.
*
* Stripe owns the recurring billing retries, dunning, off-session SCA,
* invoice numbering so it needs to know what it is billing for. It does not
* need to know how big the VM is, and it is not asked.
*
* Idempotent by construction: a row that already carries an id is skipped. That
* matters more here than usual, because a Stripe Price cannot be edited, so a
* second run that minted duplicates would leave two live prices for one plan
* and no way to tell which a customer is on.
*
* Only PUBLISHED versions are synced. A draft has promised nothing, and a
* Product for it would be a price list entry for something that may never
* exist.
*/
class SyncStripeCatalogue extends Command
{
protected $signature = 'stripe:sync-catalogue {--dry-run : Show what would be created without touching Stripe}';
protected $description = 'Create the Stripe products and prices for the plan catalogue';
public function handle(StripeClient $stripe): int
{
$dryRun = (bool) $this->option('dry-run');
if (! $dryRun && ! $stripe->isConfigured()) {
$this->error('Stripe is not configured (STRIPE_SECRET is empty). Nothing was created.');
return self::FAILURE;
}
$created = 0;
foreach (PlanFamily::query()->with('versions.prices')->orderBy('tier')->get() as $family) {
$published = $family->versions->filter(fn (PlanVersion $version) => $version->isPublished());
// A family whose versions are all drafts has promised nothing, so
// it has no business appearing in Stripe's price list yet.
if ($published->isEmpty()) {
continue;
}
$productId = $family->stripe_product_id;
if ($productId === null) {
$this->line(" product {$family->key}{$family->name}");
$created++;
if (! $dryRun) {
$productId = $stripe->createProduct(
$family->name,
['plan_family' => $family->key, 'plan_family_id' => (string) $family->id],
// Keyed on our row, so a crash between Stripe creating
// the product and us storing its id gives back the same
// product on the next run rather than a second one.
idempotencyKey: "clupilot-product-{$family->id}",
);
$family->update(['stripe_product_id' => $productId]);
}
}
foreach ($published as $version) {
foreach ($version->prices as $price) {
if ($price->stripe_price_id !== null) {
continue;
}
$this->line(sprintf(
' price %s v%d %s %d %s',
$family->key, $version->version, $price->term, $price->amount_cents, $price->currency,
));
$created++;
if ($dryRun || $productId === null) {
continue;
}
$priceId = $stripe->createPrice(
productId: $productId,
amountCents: $price->amount_cents,
currency: $price->currency,
interval: $price->term === 'yearly' ? 'year' : 'month',
metadata: [
'plan_family' => $family->key,
'plan_version' => (string) $version->version,
'plan_version_id' => (string) $version->id,
'plan_price_id' => (string) $price->id,
],
idempotencyKey: "clupilot-price-{$price->id}",
);
// Written straight through the query builder: stripe_price_id
// is not part of what publication froze, and the model would
// otherwise have to be re-read first.
PlanPrice::query()->whereKey($price->id)->update(['stripe_price_id' => $priceId]);
}
}
}
$this->newLine();
if ($created === 0) {
$this->info('Stripe is already in step with the catalogue.');
return self::SUCCESS;
}
$this->info($dryRun
? "{$created} object(s) would be created. Run without --dry-run to create them."
: "{$created} object(s) created in Stripe.");
return self::SUCCESS;
}
/** Versions whose prices are live in Stripe, for the status line. */
public static function syncedVersions(): int
{
return PlanVersion::query()
->whereNotNull('published_at')
->whereHas('prices', fn ($q) => $q->whereNotNull('stripe_price_id'))
->count();
}
}

View File

@ -1,46 +0,0 @@
<?php
namespace App\Console;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
/**
* Scheduler tick (every minute): dispatch an advance job for every run that is
* due. Immediate dispatch on advance handles the fast path; this catches
* waiting/retrying runs and anything a crashed worker left behind.
*
* PENDING counts as left behind. A run is created and dispatched in two steps,
* and a process that dies between them leaves a paid customer with a run that
* nothing will ever pick up. Re-dispatching one that is merely fresh is free
* the runner takes a per-run lock and the second job returns immediately.
*/
class TickProvisioning
{
/**
* How long a run may sit pending before it counts as stranded.
*
* Long enough that an ordinary queue backlog is not mistaken for a crash:
* sweeping a run whose first job is merely still queued would start a
* second chain of continuations alongside the first, and both would keep
* dispatching for the rest of the pipeline.
*/
private const STRANDED_AFTER_MINUTES = 5;
public function __invoke(): void
{
ProvisioningRun::query()
->where(function ($q) {
$q
->whereIn('status', [ProvisioningRun::STATUS_RUNNING, ProvisioningRun::STATUS_WAITING])
->orWhere(fn ($stranded) => $stranded
->where('status', ProvisioningRun::STATUS_PENDING)
->where('created_at', '<=', now()->subMinutes(self::STRANDED_AFTER_MINUTES)));
})
->where(function ($q) {
$q->whereNull('next_attempt_at')->orWhere('next_attempt_at', '<=', now());
})
->get()
->each(fn (ProvisioningRun $run) => AdvanceRunJob::dispatch($run->uuid));
}
}

View File

@ -1,21 +0,0 @@
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* Refused: this address already belongs to the other identity table.
*
* R21 operators and customers are two groups of people, never one row that
* could pass for both. Keyed on the address itself, not on who happens to be
* signed in when the check runs (see the deleted Customer::assertNotAdmin(),
* which checked the caller's session and mis-fired for exactly that reason).
*/
class IdentityCollisionException extends RuntimeException
{
public function __construct(public readonly string $email)
{
parent::__construct("Refusing to create or link a portal login for {$email}: that address already belongs to an operator (R21).");
}
}

View File

@ -2,9 +2,7 @@
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
//
}

View File

@ -1,116 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Exceptions\IdentityCollisionException;
use App\Models\Customer;
use App\Models\Operator;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\URL;
/**
* Looking at a customer's portal as that customer.
*
* Not a shared cookie. Once the console has a hostname of its own, a session
* cookie set on the console host never reaches the portal host SESSION_DOMAIN
* is null and cookies are host-bound. So the handover is a signed, single-use
* link: it works regardless of cookie scope, it expires, and it leaves a record
* of who looked at whom.
*/
class ImpersonationController extends Controller
{
/** Sixty seconds is a handover, not a credential. */
private const WINDOW = 60;
public function start(Customer $customer): RedirectResponse
{
$this->authorize('customers.impersonate');
$operator = Auth::guard('operator')->user();
// impersonate.enter carries no domain of its own — a portal route,
// reached wherever the portal already answers. Its SIGNATURE is
// computed over the full absolute URL: UrlGenerator::signedRoute()
// hashes the generated URL string, host included, and
// hasCorrectSignature() re-hashes $request->url() — the ACTUAL request
// URL — on the way back. Left alone, "the generated URL" means
// whichever host issued THIS request: the console's, once it has an
// exclusive hostname of its own. RestrictAdminHost then 404s every
// non-console route reached through the console's exclusive host,
// including this one — so the link would die before the customer ever
// saw it, the exact cross-host failure this controller exists to fix.
//
// Forced to the portal's own configured root instead, derived from
// config('app.url') rather than hardcoded, and restored immediately
// after: nothing else generated later in this request should inherit
// the override.
$portalRoot = rtrim((string) config('app.url'), '/');
URL::forceRootUrl($portalRoot);
try {
$signed = URL::temporarySignedRoute(
'impersonate.enter',
now()->addSeconds(self::WINDOW),
// Addressed by CUSTOMER uuid, not user: `users` has no uuid
// column, and naming the integer key in a URL is what R11
// forbids. The portal end resolves the account the same way
// this end would have.
['customer' => $customer->uuid, 'operator' => $operator->uuid],
);
} finally {
URL::forceRootUrl(null);
}
return redirect()->away($signed);
}
/** The portal end of the handover. Signed, and good exactly once. */
public function enter(Request $request, string $customer, string $operator): RedirectResponse
{
abort_unless($request->hasValidSignature(), 403);
// Single use: the signature's own hash is the key, and it lives exactly
// as long as the link could. Redis, not the database — the marker is as
// short-lived as the link and should not need tidying up.
$marker = 'impersonate:'.hash('sha256', (string) $request->query('signature'));
abort_unless(Cache::add($marker, true, self::WINDOW), 403);
// Stale data, not a normal outcome: a customer row sharing its email
// with an operator should never have been possible to reach this far
// (see Customer::ensureUser() and CreateNewUser, R21) — refuse rather
// than hand the operator's own address a portal session.
try {
$target = Customer::where('uuid', $customer)->firstOrFail()->ensureUser();
} catch (IdentityCollisionException) {
abort(409);
}
$who = Operator::where('uuid', $operator)->firstOrFail();
Auth::guard('web')->login($target);
session(['impersonator_id' => $who->id]);
return redirect()->route('dashboard');
}
/** Back out of the customer's portal. The operator session was never touched. */
public function leave(): RedirectResponse
{
session()->forget('impersonator_id');
Auth::guard('web')->logout();
// By NAME, not AdminArea::home(): this route is reached on the
// PORTAL's host, and home() is a bare path ('/' in exclusive mode) —
// resolved against whatever host the request is already on, which is
// the portal's, not the console's. admin.overview is domain-bound to
// the console in exclusive mode, so route() generates the correct
// cross-host, absolute URL back to it; in shared mode it resolves
// against the current host same as home() would, so nothing changes
// there.
return redirect()->route('admin.overview');
}
}

View File

@ -1,167 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* The public price sheet.
*
* Prices, storage and seat counts are read from the catalogue rather than
* written into the page. They were hard-coded here once, and the page and the
* catalogue had already drifted apart on three of four plans a visitor was
* quoted 249 for a plan that charged 399 at checkout. The marketing site is
* a reader of the catalogue like every other caller.
*
* The catalogue fails loudly by design: an empty or overlapping catalogue is an
* outage for commerce, not something to paper over. But a public website is not
* commerce a mistyped availability window must not take the company's front
* page down with it. So the failure is caught HERE and only here: the page
* still renders, the price sheet is replaced by "on request", and nobody is
* shown a number the checkout would not honour.
*/
class LandingController extends Controller
{
/** Presentation that belongs to the marketing page, not to the catalogue. */
private const COPY = [
'start' => ['audience' => 'Für kleine Teams', 'note' => 'Einstieg für Büros, die heute noch Dateien per Mail schicken.'],
'team' => ['audience' => 'Für wachsende Teams', 'note' => 'Der Regelfall — Einschulung des gesamten Teams inklusive.'],
'business' => ['audience' => 'Für dokumentenlastige Betriebe', 'note' => 'Mehr Speicher, längere Aufbewahrung, bevorzugte Reaktionszeit.'],
'enterprise' => ['audience' => 'Individuell für Ihr Unternehmen', 'note' => 'Umfang, Einrichtung und Reaktionszeiten nach Vereinbarung.'],
];
/** Catalogue feature keys, in the words a customer uses. */
private const FEATURES = [
'managed_updates' => 'Updates & Wartung',
'daily_backups' => 'Tägliche Sicherung',
'monitoring' => 'Überwachung rund um die Uhr',
'subdomain' => 'Adresse auf clupilot.cloud',
'custom_domain' => 'Eigene Domain',
'office' => 'Office im Browser',
'branding' => 'Ihr Logo & Ihre Farben',
'priority_support' => 'Bevorzugter Support',
'premium_sla' => 'Vereinbarte Reaktionszeiten',
'extended_retention' => 'Verlängerte Aufbewahrung',
'audit_log' => 'Protokollierung der Zugriffe',
'onboarding' => 'Begleitete Einführung',
];
/** The plan carrying the recommendation mark. */
private const RECOMMENDED = 'team';
public function __invoke(): View
{
$plans = $this->plans();
return view('landing', [
'plans' => $plans,
'featureRows' => $this->featureRows($plans),
'recommended' => self::RECOMMENDED,
]);
}
/**
* Every feature any sellable plan carries, in the catalogue's narrative
* order the row headings of the price sheet.
*
* Built from what is on sale rather than from the full list, so a feature
* no current plan offers does not appear as an empty row.
*
* @param array<int, array<string, mixed>> $plans
* @return array<int, string>
*/
private function featureRows(array $plans): array
{
$offered = array_merge(...array_column($plans, 'features')) ?: [];
return array_values(array_filter(
self::FEATURES,
fn (string $label) => in_array($label, $offered, true),
));
}
/**
* @return array<int, array<string, mixed>> empty when the catalogue cannot be read
*/
private function plans(): array
{
try {
$sellable = app(PlanCatalogue::class)->sellable();
} catch (Throwable $e) {
// Deliberately swallowed: see the class docblock. Logged at error
// level because a shop that cannot list its plans is not selling.
Log::error('Landing page could not read the plan catalogue', ['exception' => $e]);
return [];
}
$plans = [];
foreach ($sellable as $key => $plan) {
$plans[] = [
'key' => $key,
'name' => $plan['name'],
'audience' => self::COPY[$key]['audience'] ?? '',
'note' => self::COPY[$key]['note'] ?? '',
'price' => $this->money((int) $plan['price_cents'], (string) $plan['currency']),
'storage' => $this->storage((int) $plan['quota_gb']),
'traffic' => $this->storage((int) $plan['traffic_gb']),
'seats' => (int) $plan['seats'],
'features' => $this->features($plan['features'] ?? []),
'recommended' => $key === self::RECOMMENDED,
];
}
return $plans;
}
/** Currencies we have a symbol for; anything else prints its ISO code. */
private const SYMBOLS = ['EUR' => '€', 'CHF' => 'CHF', 'USD' => '$', 'GBP' => '£'];
/**
* A price as the sheet prints it, currency included.
*
* The symbol comes from the catalogue rather than from the template: the
* currency is configurable (CLUPILOT_CURRENCY), and a page that says ""
* while the checkout charges francs is the same two-sources-of-truth
* mistake as a hard-coded amount, only harder to notice.
*
* Whole units when the amount is whole a price sheet reads "179", not
* "179,00".
*/
private function money(int $cents, string $currency): string
{
$amount = $cents % 100 === 0
? number_format($cents / 100, 0, ',', '.')
: number_format($cents / 100, 2, ',', '.');
// Non-breaking: in a narrow table column "49 €" otherwise wraps, and the
// currency ends up on a line of its own under the number.
return $amount."\u{00A0}".(self::SYMBOLS[strtoupper($currency)] ?? strtoupper($currency));
}
private function storage(int $gb): string
{
return $gb >= 1000 && $gb % 1000 === 0
? ($gb / 1000).' TB'
: $gb.' GB';
}
/**
* @param array<int, string> $keys
* @return array<int, string>
*/
private function features(array $keys): array
{
// Unknown keys are dropped rather than printed raw: a feature added to
// the catalogue without a translation would otherwise appear on the
// public page as "premium_sla".
return array_values(array_filter(array_map(
fn (string $key) => self::FEATURES[$key] ?? null,
$keys,
)));
}
}

View File

@ -1,194 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Instance;
use App\Models\MonitoringTarget;
use App\Models\ProvisioningRun;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Carbon;
/**
* The public service status page.
*
* It lived at /legal/status, next to the imprint and the terms, which is a
* filing mistake: nothing about the current health of the platform is a legal
* document. It has its own address now.
*
* Every line on it is derived from a record. Where there is no signal the
* component says "not monitored" rather than "operational" a status page that
* reports green because it has nothing to look at is worse than no status page,
* because someone will believe it.
*
* Aggregate only: counts, never customer names, instance addresses or host
* names. This page is world-readable and the estate is not public information.
*/
class StatusController extends Controller
{
/** A backup older than this stops counting as current. */
private const BACKUP_STALE_AFTER_HOURS = 48;
/**
* How old a monitoring verdict may be before it stops counting.
*
* The sync job runs every five minutes; four missed runs is a monitoring
* pipeline that has stopped, and a verdict from then says nothing about now.
*/
private const MONITORING_STALE_AFTER_MINUTES = 20;
/** How far back a provisioning failure still says something about now. */
private const PROVISIONING_WINDOW_HOURS = 24;
public function __invoke(): View
{
$components = [
$this->portal(),
$this->instances(),
$this->provisioning(),
$this->backups(),
];
// The worst individual state is the state of the whole thing. Averaging
// it would let one outage disappear behind three healthy components.
$overall = match (true) {
in_array('down', array_column($components, 'state'), true) => 'down',
in_array('degraded', array_column($components, 'state'), true) => 'degraded',
in_array('unknown', array_column($components, 'state'), true) => 'unknown',
default => 'operational',
};
return view('status', [
'components' => $components,
'overall' => $overall,
'checkedAt' => Carbon::now(),
]);
}
/**
* The portal and the website.
*
* Answering this request is the measurement. There is no honest way for a
* page to report that the server serving it is down.
*
* @return array<string, mixed>
*/
private function portal(): array
{
return [
'key' => 'portal',
'state' => 'operational',
'detail' => null,
];
}
/**
* Customer instances, from what monitoring last saw.
*
* @return array<string, mixed>
*/
private function instances(): array
{
$total = Instance::query()->where('status', 'active')->count();
if ($total === 0) {
return ['key' => 'instances', 'state' => 'operational', 'detail' => null];
}
// Only targets belonging to instances that are actually in service. A
// healthy check on a decommissioned instance says nothing about a live
// one, and counting it would let coverage look complete when it is not.
$onActive = fn ($query) => $query->whereHas(
'instance',
fn ($instance) => $instance->where('status', 'active'),
);
// Only verdicts the sync job has actually refreshed. A row that has
// never been checked, or was last checked long enough ago that the
// answer means nothing, is not evidence of health — and this column was
// for a long time exactly that: written 'up' at provisioning and never
// touched again.
$fresh = Carbon::now()->subMinutes(self::MONITORING_STALE_AFTER_MINUTES);
$checked = fn ($query) => $query->tap($onActive)->where('checked_at', '>=', $fresh);
$watched = MonitoringTarget::query()->tap($checked)->distinct()->count('instance_id');
$down = MonitoringTarget::query()->tap($checked)->where('status', '!=', 'up')->distinct()->count('instance_id');
// Down first, then coverage. An instance nobody is watching is not a
// healthy instance — it is an unanswered question, and reporting the
// absence of a check as the absence of a problem is the one thing this
// page must never do.
return [
'key' => 'instances',
'state' => match (true) {
$down > 0 && $down >= $watched => 'down',
$down > 0 => 'degraded',
$watched < $total => 'unknown',
default => 'operational',
},
'detail' => match (true) {
$down > 0 => ['down' => $down, 'total' => $watched],
$watched < $total => ['down' => $total - $watched, 'total' => $total],
default => null,
},
];
}
/**
* Whether new instances are being delivered.
*
* @return array<string, mixed>
*/
private function provisioning(): array
{
$since = Carbon::now()->subHours(self::PROVISIONING_WINDOW_HOURS);
$failed = ProvisioningRun::query()
->where('status', ProvisioningRun::STATUS_FAILED)
->where('updated_at', '>=', $since)
->count();
return [
'key' => 'provisioning',
'state' => $failed === 0 ? 'operational' : 'degraded',
'detail' => $failed > 0 ? ['failed' => $failed] : null,
];
}
/**
* Whether the last backup of every instance is recent.
*
* @return array<string, mixed>
*/
private function backups(): array
{
// Counted from the INSTANCES that need protecting, not from the backup
// rows that happen to exist. An active instance with no schedule at all
// has no row — so counting rows would leave it out of the arithmetic
// entirely and report the estate as protected because the backups that
// do exist are fine.
$total = Instance::query()->where('status', 'active')->count();
if ($total === 0) {
return ['key' => 'backups', 'state' => 'operational', 'detail' => null];
}
$fresh = Carbon::now()->subHours(self::BACKUP_STALE_AFTER_HOURS);
$protected = Instance::query()
->where('status', 'active')
->whereHas('backups', fn ($backup) => $backup->where('last_ok_at', '>=', $fresh))
->count();
$unprotected = $total - $protected;
return [
'key' => 'backups',
'state' => match (true) {
$unprotected === 0 => 'operational',
$unprotected >= $total => 'down',
default => 'degraded',
},
'detail' => $unprotected > 0 ? ['stale' => $unprotected, 'total' => $total] : null,
];
}
}

View File

@ -1,137 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Actions\ApplyStripeBillingEvent;
use App\Actions\StartCustomerProvisioning;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Stripe webhook: verifies the signature (when a secret is configured) and turns
* a paid checkout into a provisioning run. Idempotent via the order's
* stripe_event_id Stripe retries a webhook until it gets a 2xx.
*/
class StripeWebhookController extends Controller
{
public function __invoke(
Request $request,
StartCustomerProvisioning $action,
ApplyStripeBillingEvent $billing,
): JsonResponse {
$payload = $request->getContent();
$secret = (string) config('services.stripe.webhook_secret');
if (blank($secret)) {
// Fail closed: an unconfigured secret must not authorize provisioning
// outside local/testing.
abort_unless(app()->environment('local', 'testing'), 400, 'Stripe webhook secret not configured');
} elseif (! $this->signatureValid($payload, (string) $request->header('Stripe-Signature'), $secret)) {
abort(400, 'invalid signature');
}
$event = json_decode($payload, true) ?: [];
$object = $event['data']['object'] ?? [];
$type = $event['type'] ?? '';
// The billing cycle is Stripe's: once a contract exists, they decide
// when it renews, when a payment failed, and when it has ended. Handled
// before the checkout branch because none of these are checkouts.
$applied = $billing->dispatch($event);
if ($applied !== false) {
if ($applied === null) {
// Either already applied, or about a contract that does not
// exist yet — a checkout takes a moment to become one, and
// Stripe does not deliver in order. Holding it costs a row and
// saves a cancellation we would otherwise never hear about
// again; a replay is a no-op if it was simply a duplicate.
$billing->hold($event);
}
// 2xx either way. Stripe retrying would not change the answer, and
// anything worth replaying is now held rather than dropped.
return response()->json(['handled' => $type, 'applied' => $applied !== null]);
}
// Paid triggers: a synchronous checkout (completed + paid) OR an async
// method clearing later (async_payment_succeeded). Ignore everything else,
// including the still-unpaid completed event for async methods.
$paid = ($type === 'checkout.session.completed' && ($object['payment_status'] ?? null) === 'paid')
|| $type === 'checkout.session.async_payment_succeeded';
if (! $paid) {
return response()->json(['ignored' => true]);
}
$meta = $object['metadata'] ?? [];
// A real email is required — never merge unrelated customers under a
// manufactured address or send credentials into the void.
$email = $object['customer_details']['email'] ?? $object['customer_email'] ?? ($meta['email'] ?? null);
if (blank($email)) {
return response()->json(['ignored' => 'no_customer_email']);
}
$action->fromStripeEvent([
// Deduplicate on the checkout session id (stable across the completed
// and async-succeeded events for one purchase), not the event id.
'id' => (string) ($object['id'] ?? $event['id'] ?? ''),
'email' => $email,
'name' => $object['customer_details']['name'] ?? ($meta['name'] ?? null),
'stripe_customer_id' => $object['customer'] ?? null,
// The handle every later billing event arrives with. Without it an
// invoice.paid cannot be matched to the contract it renews.
'stripe_subscription_id' => is_string($object['subscription'] ?? null) ? $object['subscription'] : null,
'plan' => $meta['plan'] ?? 'start',
// What the customer was actually shown. Absent on a session created
// before phase 5 put it there, and then the version on sale applies.
'plan_version_id' => isset($meta['plan_version_id']) ? (int) $meta['plan_version_id'] : null,
'datacenter' => $meta['datacenter'] ?? 'fsn',
'amount_cents' => (int) ($object['amount_total'] ?? $object['amount'] ?? 0),
'currency' => strtoupper($object['currency'] ?? 'eur'),
]);
return response()->json(['ok' => true]);
}
/** Stripe's default replay tolerance, in seconds. */
private const SIGNATURE_TOLERANCE = 300;
/** Verify Stripe's `t=…,v1=…` signature: HMAC-SHA256 of "{t}.{payload}". */
private function signatureValid(string $payload, string $header, string $secret): bool
{
if (blank($header)) {
return false;
}
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $segment) {
[$key, $value] = array_pad(explode('=', $segment, 2), 2, '');
if ($key === 't') {
$timestamp = $value;
} elseif ($key === 'v1') {
$signatures[] = $value; // keep every v1 (secret rotation sends several)
}
}
if ($timestamp === null || $signatures === []) {
return false;
}
// Reject replays outside the tolerance window.
if (abs(time() - (int) $timestamp) > self::SIGNATURE_TOLERANCE) {
return false;
}
$expected = hash_hmac('sha256', $timestamp.'.'.$payload, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
}

View File

@ -1,26 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class EnsureAdmin
{
/**
* Only operators with the console.view capability may reach the admin
* console. Checked on the operator guard explicitly, never on the default
* (`web`) guard's user a portal account can no longer hold a role at all,
* so trusting the default guard here would just mean nobody ever gets in.
*/
public function handle(Request $request, Closure $next): Response
{
$operator = Auth::guard('operator')->user();
abort_unless($operator !== null && $operator->isActive() && $operator->can('console.view'), 403);
return $next($request);
}
}

View File

@ -1,54 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Models\Customer;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* Enforce customer account lifecycle on portal requests: a suspended or closed
* customer must lose access. An active impersonation session is exempt so
* operators can still inspect a suspended customer's portal. There is no
* operator special case here any more: a `web`-guard user can never be an
* operator, so the only thing left to exempt is an impersonation session.
*/
class EnsureCustomerActive
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user !== null && ! $request->session()->has('impersonator_id')) {
$customer = Customer::query()->where('user_id', $user->id)->first()
?? Customer::query()->where('email', $user->email)->first();
if ($customer !== null && ($customer->status === 'suspended' || $customer->status === 'closed' || $customer->closed_at !== null)) {
Auth::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 'web' guard's, so it would silently sign
// an operator elsewhere in that same browser out of the console
// too. regenerate() issues a new session id (no fixation risk)
// without touching the other attributes, so the 'operator'
// guard's own login key — untouched by the Auth::logout() call
// above, which only logs out the default ('web') guard —
// survives untouched. Same fix as routes/admin.php's own
// /logout route, mirrored the other way round.
$request->session()->regenerate();
$request->session()->regenerateToken();
$key = $customer->closed_at !== null || $customer->status === 'closed' ? 'auth.account_closed' : 'auth.account_suspended';
return redirect()->route('login')->withErrors(['email' => __($key)]);
}
}
return $next($request);
}
}

View File

@ -1,97 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Support\AdminArea;
use App\Support\Settings;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\Response;
/**
* Hides the marketing site and the customer portal while the product is still
* being built, without hiding it from us.
*
* Switched from the console (site.public). Anyone coming through the management
* VPN, and any signed-in operator, sees the real thing; everyone else crawlers
* included gets a placeholder.
*
* Answers 503 with Retry-After and X-Robots-Tag rather than 200: a 200 would
* invite search engines to index the placeholder as the site's content, and
* getting that out of an index again is much harder than keeping it out.
*/
class PublicSiteGate
{
/**
* Paths that must keep working regardless: the console itself (otherwise the
* switch could not be flipped back), Stripe's webhook, and the health check.
*
* Livewire is deliberately NOT in this list. Its endpoint is shared by the
* console and the portal, so exempting it would leave a signed-in customer
* able to drive portal components including billing while the portal is
* supposed to be offline. Operators pass the check below anyway, which is
* what the console actually needs.
*
* Nor is /login, and that has a consequence worth knowing before you hide
* the site: /admin sends a guest to /login, which is not the console, so
* SIGNING IN while hidden needs the request to come from a trusted range
* the management VPN, or an address put in TRUSTED_RANGES for the initial
* setup. Exempting the login flow by hostname instead would mean trusting a
* Host header, which the caller chooses, and one forged header would unhide
* the entire portal.
*/
private const ALWAYS_ALLOWED = ['webhooks/*', 'up', 'robots.txt'];
public function handle(Request $request, Closure $next): Response
{
// The console is exempt wherever it currently lives. This used to be
// the literal paths 'admin' and 'admin/*', which stops matching the
// moment the console moves to the root of its own hostname — and the
// result would be 503 on the sign-in page of the console, which is the
// only place the switch can be turned back on. Asking AdminArea keeps
// the exemption attached to the console rather than to a path that
// happened to be true once.
if (AdminArea::isConsole($request) || $request->is(...self::ALWAYS_ALLOWED) || Settings::bool('site.public', true)) {
return $next($request);
}
if ($this->fromManagementNetwork($request) || $this->isActiveOperator()) {
return $next($request);
}
return response()->view('coming-soon', [], 503)->withHeaders([
'Retry-After' => 3600,
'X-Robots-Tag' => 'noindex, nofollow, noarchive',
'Cache-Control' => 'no-store',
]);
}
private function fromManagementNetwork(Request $request): bool
{
$ranges = (array) config('admin_access.trusted_ranges', []);
return $ranges !== [] && IpUtils::checkIp((string) $request->ip(), $ranges);
}
/**
* Same standard as EnsureAdmin: an active operator holding a console role.
* A bare `check()` stays true for a disabled operator's live session
* disabling someone did not sign them out, so it never stopped this gate
* from waving them through indefinitely.
*
* Deliberately checked, not enforced by logging out: this gate only
* decides what ONE request sees, and destroying the session would ripple
* into whatever else that session is doing (a concurrent console tab,
* mid-task) a much bigger side effect than a visibility check exists to
* have. EnsureAdmin sets the precedent: it also 403s a disabled operator
* per request without touching their session.
*/
private function isActiveOperator(): bool
{
$operator = Auth::guard('operator')->user();
return $operator !== null && $operator->isActive() && $operator->can('console.view');
}
}

View File

@ -1,56 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Support\AdminArea;
use App\Support\Settings;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* Two-factor for operators, when the owner has made it compulsory.
*
* Off by default. ENROLMENT (admin.two-factor-setup) is exempt, because it is
* where the operator sets two-factor up gating it would leave nobody able
* to satisfy the requirement. Nothing else is.
*
* This used to exempt the whole of admin.settings, on the theory that it was
* "the page where two-factor is set up". That component also carries staff
* management, site visibility, network restrictions, account changes, and
* the two-factor policy switch itself so an unenrolled operator got
* unrestricted access to all of that, not just enrolment (Codex R15, P1b).
* Enrolment has its own route and component for exactly this reason: one
* page to exempt is one decision, not eight actions each needing to
* remember to check.
*/
class RequireOperatorTwoFactor
{
public function handle(Request $request, Closure $next): Response
{
if (! Settings::bool('console.require_2fa', false)) {
return $next($request);
}
$operator = Auth::guard('operator')->user();
if ($operator === null || $operator->two_factor_confirmed_at !== null) {
return $next($request);
}
// The two pages that must stay reachable: where two-factor is
// enrolled, and signing out. Both through AdminArea::routeIs(), not
// a bare $request->routeIs() — a recovery hostname's routes are
// named admin.viaN.*, and a bare check matches only the canonical
// name, which would leave an unenrolled operator unable to sign out
// on exactly the host that exists because the canonical one is not
// working.
if (AdminArea::routeIs('admin.two-factor-setup') || AdminArea::routeIs('admin.logout')) {
return $next($request);
}
return redirect()->route('admin.two-factor-setup')
->with('status', __('admin_settings.two_factor_required'));
}
}

View File

@ -1,103 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Support\AdminArea;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* The console answers only on its own hostname and that hostname answers
* only the console.
*
* Both halves matter. The first keeps the console off the public domains. The
* second is what actually separates the two products: binding the console
* routes to a hostname does not stop the CUSTOMER routes from answering there
* as well, because they are registered without a hostname. Without this,
* app.clupilot.com and admin.clupilot.com would still serve each other's pages
* wherever the paths did not collide.
*
* 404 in both directions, never 403: a stranger must not learn that a console
* lives here, and an operator on the wrong host should see the same nothing a
* stranger does.
*
* A handful of endpoints are shared by both sides and must answer on either
* host Livewire's own endpoints and the authentication actions. They are
* listed explicitly below, because the failure mode of getting that list wrong
* is "the console is broken" rather than "the rule is wrong", and a list you
* can read is easier to correct than a rule you have to infer.
*/
class RestrictAdminHost
{
/**
* Endpoints both the console and the portal need, on whichever host the
* caller is currently on.
*
* `login`, `logout` and `two-factor-challenge` used to be here, from when
* one sign-in page served both. They are gone: the console has its own at
* `admin.login`, and leaving the portal's here is what put a registration
* link in front of an operator with a 404 behind it, because `register`
* was never in this list and could not be, since the console has no
* registration by design.
*
* Livewire's component endpoint is guarded separately and more strictly:
* the console's own middleware is registered as persistent, so an action
* posted to /livewire/update is re-checked against the component's real
* route. Letting the path through here does not let anything through there.
*/
private const SHARED = [
'livewire/*',
'up',
// The console's own live-provisioning feed authorizes here (see
// routes/channels.php, admin.runs) — a route Laravel registers
// without any admin.* name, so isConsoleRoute() alone would not
// exempt it, and in exclusive mode it would 404 on the console host
// exactly like a stranger's request would.
'broadcasting/auth',
];
public function handle(Request $request, Closure $next): Response
{
// Not host-bound (development, a fresh checkout): the console keeps its
// /admin prefix on any host and nothing is separated. Upgrading must
// not lock anyone out of a system that was working.
if (! AdminArea::isHostBound()) {
return $next($request);
}
$isConsoleRoute = $this->isConsoleRoute($request);
$onConsoleHost = AdminArea::covers($request->getHost());
// The console, reached through a hostname that is not the console's.
if ($isConsoleRoute && ! $onConsoleHost) {
abort(404);
}
// The portal or the public site, reached through the console's
// hostname. Only once exclusivity is switched on: a machine that lists
// its own IP in ADMIN_HOSTS so the console is reachable without DNS
// still has to serve the portal from that same address.
if (AdminArea::isExclusive() && $onConsoleHost && ! $isConsoleRoute && ! $request->is(...self::SHARED)) {
abort(404);
}
return $next($request);
}
/**
* Console routes are the ones named `admin.*`.
*
* By name rather than by path: the path is exactly what changes between
* host-bound and fallback mode, and a rule written against it would be
* wrong in one of them.
*/
private function isConsoleRoute(Request $request): bool
{
$name = $request->route()?->getName();
// Covers admin.* and the alternate-host registrations (admin.viaN.*),
// which are the same console reached by a recovery hostname.
return $name !== null && str_starts_with($name, 'admin.');
}
}

View File

@ -1,134 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Support\AdminArea;
use App\Support\Settings;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\Response;
/**
* Who may reach the operator console, by network address.
*
* RestrictAdminHost answers "under which NAME does the console respond" and a
* Host header is chosen by the caller, so it can never answer "who is asking".
* This one can: the client address behind a trusted proxy is not something the
* client gets to pick.
*
* The management VPN always counts. Beyond that the owner keeps a list an
* office line, a home connection so being away from the VPN does not mean
* being locked out. That list lives in the console rather than in a proxy
* config file, because the person who needs to change it is the person sitting
* in the console.
*
* 404, never 403: a stranger must not learn that a console lives here.
*
* This is not a substitute for a firewall. It runs after PHP has started, so it
* bounds who can USE the console, not who can make the server work. Where a
* network-level ACL is also possible, both belong there.
*/
class RestrictConsoleNetwork
{
public function handle(Request $request, Closure $next): Response
{
// Scoped to the console, wherever the console currently is. Asking
// AdminArea rather than testing the path: once the console moves to the
// root of its own hostname there is no `/admin` left to match, and a
// guard that silently stops matching is worse than no guard.
if (! AdminArea::isConsole($request)) {
return $next($request);
}
if (! self::isRestricted()) {
return $next($request);
}
if (! self::allows((string) $request->ip())) {
abort(404);
}
return $next($request);
}
/** Whether the owner has switched the restriction on at all. */
public static function isRestricted(): bool
{
return Settings::bool('console.network_restricted', false);
}
/**
* Every network that may reach the console.
*
* The VPN is always in it and is not removable it is the one path that
* exists independently of whatever the owner has typed into the list, and
* without it a bad entry would leave nobody able to fix the entry.
*
* @return array<int, string>
*/
public static function allowedRanges(): array
{
$vpn = (array) config('admin_access.trusted_ranges', []);
$own = (array) Settings::get('console.allowed_ips', []);
return array_values(array_unique(array_filter(array_merge(
$vpn,
array_map('trim', array_map('strval', $own)),
))));
}
public static function allows(string $ip): bool
{
return self::covers($ip, self::allowedRanges());
}
/**
* Would this address still get in, given that list?
*
* Split out so the "you are about to lock yourself out" decision can be
* made and tested without a request: it is the one check whose failure
* mode is that nobody can reach the page that would fix it.
*
* @param array<int, string> $extra the owner's list AFTER the change
*/
public static function wouldStillAllow(string $ip, array $extra): bool
{
$vpn = (array) config('admin_access.trusted_ranges', []);
return self::covers($ip, array_merge($vpn, $extra));
}
/**
* Whether a value is an address or a CIDR range at all.
*
* Shared by the console and the recovery command on purpose: an entry that
* matches nothing is stored happily and reports success, and the operator
* then switches the restriction on believing they are covered. That is the
* exact situation the recovery command exists to get out of.
*/
public static function isNetwork(string $value): bool
{
[$address, $prefix] = array_pad(explode('/', $value, 2), 2, null);
if (filter_var($address, FILTER_VALIDATE_IP) === false) {
return false;
}
if ($prefix === null) {
return true;
}
$max = str_contains((string) $address, ':') ? 128 : 32;
return ctype_digit((string) $prefix) && (int) $prefix >= 0 && (int) $prefix <= $max;
}
/** @param array<int, string> $ranges */
private static function covers(string $ip, array $ranges): bool
{
$ranges = array_values(array_filter($ranges));
return $ranges !== [] && $ip !== '' && IpUtils::checkIp($ip, $ranges);
}
}

View File

@ -1,74 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Models\LoginSession;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
/**
* Keeps a session row pointing at the session it describes.
*
* Laravel rotates the session id on every sign-in and on every regenerate().
* A row that stored the id once stops matching within a request or two and
* the list would then show sessions that cannot be ended and hide the one the
* reader is sitting in. The uuid inside the payload is what survives; the id is
* refreshed from it here.
*
* Also the only thing that keeps "last seen" honest. Without it the list
* reports the moment somebody signed in, which for a browser left open for a
* fortnight is the least useful timestamp available.
*
* Written at most once a minute. This runs on every authenticated request, and
* a write per request would turn a page of thumbnails into forty updates of the
* same row.
*/
class TouchLoginSession
{
private const THROTTLE_SECONDS = 60;
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (! $request->hasSession()) {
return $response;
}
$uuid = $request->session()->get(LoginSession::KEY);
if (! is_string($uuid) || $uuid === '') {
return $response;
}
$current = $request->session()->getId();
// A bare update rather than a model round-trip: this is on the path of
// every authenticated request, and the row is not needed, only moved.
DB::table('login_sessions')
->where('uuid', $uuid)
->where(function ($q) use ($current) {
// A CHANGED id is never throttled. Both sign-in paths raise the
// Login event before calling session()->regenerate(), so the row
// is written against an id that is dead a moment later. Held
// back by the throttle, the row would point at nothing for the
// first minute of every session — the one it describes missing
// from the list, and "end this one" deleting a session that no
// longer exists.
$q->where('session_id', '!=', $current)
->orWhereNull('session_id')
->orWhereNull('last_seen_at')
->orWhere('last_seen_at', '<=', now()->subSeconds(self::THROTTLE_SECONDS));
})
->update([
'session_id' => $current,
'ip_address' => $request->ip(),
'last_seen_at' => now(),
'updated_at' => now(),
]);
return $response;
}
}

View File

@ -1,70 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\ExportTarget;
use App\Models\Invoice;
use App\Models\InvoiceExport;
use App\Services\Billing\InvoiceArchive;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
/**
* Put one invoice on ONE destination, and keep trying if that one is away.
*
* One job per pair rather than one per invoice: the reason for having a second
* destination is that the first can fail, and a job that writes to both would
* retry the one that worked every time the other did not and would report a
* single outcome for two different things.
*
* Queued rather than done during the request that issued the invoice: a network
* mount can block for its whole timeout, and an invoice must not fail to be
* issued because a NAS in an office is rebooting.
*/
class ArchiveInvoice implements ShouldQueue
{
use Queueable;
public int $tries = 5;
/** A destination that is gone is usually gone for a while. */
public array $backoff = [60, 300, 900, 3600];
public function __construct(public Invoice $invoice, public ExportTarget $target) {}
public function handle(InvoiceArchive $archive): void
{
if (! $this->target->active) {
return;
}
$record = InvoiceExport::query()->firstOrNew([
'invoice_id' => $this->invoice->id,
'export_target_id' => $this->target->id,
]);
// Already there. A retry after a timeout that in fact succeeded must
// not write it a second time.
if ($record->exists && $record->exported_at !== null) {
return;
}
$record->attempts = (int) $record->attempts + 1;
$record->save();
$archive->store($this->invoice, $this->target);
$record->forceFill(['exported_at' => now(), 'error' => null])->save();
}
public function failed(?Throwable $e): void
{
// Against the pair, not the invoice: "on the office NAS, not on the
// off-site box" is the thing somebody needs to be told.
InvoiceExport::query()->updateOrCreate(
['invoice_id' => $this->invoice->id, 'export_target_id' => $this->target->id],
['error' => mb_substr((string) $e?->getMessage(), 0, 250)],
);
}
}

View File

@ -1,66 +0,0 @@
<?php
namespace App\Listeners;
use App\Mail\NewDeviceSignInMail;
use App\Services\Devices\DeviceRecognition;
use Illuminate\Auth\Events\Login;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* One listener for both ways in.
*
* The console signs in through App\Livewire\Auth\OperatorLogin and the portal
* through Fortify two code paths that share nothing. Both raise Laravel's
* Login event, and it carries the guard, which is the one thing that tells
* operators and customers apart (R21). Hooking the event rather than the two
* controllers means neither can be changed in a way that quietly stops
* recording sign-ins.
*
* Nothing here may prevent a sign-in. Recognising a device is a safeguard, and
* a safeguard that locks the owner out when it breaks has stopped being one
* so a failure is logged and swallowed. This is a deliberate exception to
* failing loudly, and the reason is written here so nobody removes it as an
* oversight.
*/
class RecordSignInDevice
{
public function __construct(
private readonly DeviceRecognition $devices,
private readonly Request $request,
) {}
public function handle(Login $event): void
{
try {
$outcome = $this->devices->recognise($event->user, $event->guard, $this->request);
$this->devices->attachSession($outcome->device, $this->request);
if (! $outcome->shouldWarn) {
return;
}
$address = $event->user->getAttribute('email');
if (! is_string($address) || $address === '') {
return;
}
// Queued: a sign-in must not wait on a mail server, and this one
// runs while somebody is looking at a spinner on the login button.
Mail::to($address)->queue(new NewDeviceSignInMail(
name: (string) ($event->user->getAttribute('name') ?? ''),
device: $outcome->device,
guard: $event->guard,
));
} catch (Throwable $e) {
Log::warning('Could not record the device for a sign-in', [
'guard' => $event->guard,
'exception' => $e->getMessage(),
]);
}
}
}

View File

@ -1,60 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use App\Models\Host;
use Illuminate\Database\QueryException;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for deleting a datacenter (R5). Guarded: a datacenter that still
* has hosts cannot be deleted it would orphan host placement so the modal
* blocks and explains instead. Otherwise it is safe to hard-delete an unused code.
*/
class ConfirmDeleteDatacenter extends ModalComponent
{
public string $uuid;
public string $name = '';
public int $hostCount = 0;
public function mount(string $uuid): void
{
$this->authorize('datacenters.manage'); // modals are reachable without the route middleware
$dc = Datacenter::query()->where('uuid', $uuid)->withCount('hosts')->firstOrFail();
$this->uuid = $uuid;
$this->name = $dc->name;
$this->hostCount = $dc->hosts_count;
}
public function delete()
{
$this->authorize('datacenters.manage');
$dc = Datacenter::query()->where('uuid', $this->uuid)->first();
if ($dc === null) {
return $this->redirectRoute('admin.datacenters', navigate: true);
}
// The hosts.datacenter → datacenters.code foreign key (restrictOnDelete)
// is the source of truth: the DB refuses to orphan a host. We pre-check
// for a friendly message and catch the constraint as the race backstop.
if (Host::query()->where('datacenter', $dc->code)->exists()) {
return $this->redirectRoute('admin.datacenters', navigate: true);
}
try {
$dc->delete();
} catch (QueryException) {
// A host was created for this code concurrently — leave it intact.
}
return $this->redirectRoute('admin.datacenters', navigate: true);
}
public function render()
{
return view('livewire.admin.confirm-delete-datacenter');
}
}

View File

@ -1,60 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\PlanVersion;
use App\Services\Billing\PlanCatalogue;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for discarding an unpublished plan version.
*
* Only a draft can be discarded at all a published version is what customers
* are contracted to, and the model refuses to delete one. So this is a small
* destructive action, and the modal says which one it is rather than leaving
* "delete" next to a row that might be either.
*/
class ConfirmDeletePlanDraft extends ModalComponent
{
public string $uuid;
public int $version;
public string $plan = '';
public function mount(string $uuid): void
{
// Modals are reachable without the page's guards, so this is the real
// check, not a convenience one.
$this->authorize('plans.manage');
$version = PlanVersion::query()->with('family')->where('uuid', $uuid)->firstOrFail();
abort_if($version->isPublished(), 403);
$this->uuid = $uuid;
$this->version = $version->version;
$this->plan = $version->family->name;
}
public function delete(): void
{
$this->authorize('plans.manage');
$version = PlanVersion::query()->where('uuid', $this->uuid)->first();
// The catalogue decides, in one conditional statement. Checking here and
// deleting afterwards would leave a window in which the version is
// published between the two — and a published version must never go.
if ($version !== null && ! app(PlanCatalogue::class)->discardDraft($version)) {
$this->dispatch('notify', message: __('plans.discard_too_late'));
}
$this->dispatch('plan-draft-deleted');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-delete-plan-draft');
}
}

View File

@ -1,58 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\VpnPeer;
use App\Provisioning\Jobs\ApplyVpnPeer;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for revoking a VPN access for good (R5). Deleting a peer that
* belongs to a host cuts CluPilot's own management path to that machine, so the
* modal says so plainly rather than hiding the option.
*/
class ConfirmDeleteVpnPeer extends ModalComponent
{
public string $uuid;
public string $name = '';
public ?string $hostName = null;
public function mount(string $uuid): void
{
// Modals are reachable without the route middleware, so this is the
// real guard, not a convenience check.
$peer = VpnPeer::query()->with('host')->where('uuid', $uuid)->firstOrFail();
$this->authorize('delete', $peer);
$this->uuid = $uuid;
$this->name = $peer->name;
$this->hostName = $peer->host?->name;
}
public function delete(): void
{
$peer = VpnPeer::query()->where('uuid', $this->uuid)->first();
if ($peer !== null) {
$this->authorize('delete', $peer);
// A tombstone that still carries a usable private key is a liability.
$peer->purgeSecret();
// Soft-delete, so the row survives as a tombstone until the hub has
// actually dropped the peer. A hard delete here would let the next
// sync adopt the still-present peer back as a live access —
// silently restoring what was just revoked. ApplyVpnPeer purges the
// tombstone once removal succeeded; SyncVpnPeers retries if not.
$peer->delete();
ApplyVpnPeer::dispatch($peer->public_key, null, false);
}
$this->dispatch('vpn-peer-deleted');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-delete-vpn-peer');
}
}

View File

@ -1,24 +0,0 @@
<?php
namespace App\Livewire\Admin;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before an operator's own two-factor login is removed (R23).
* No mutation here TwoFactorSetup::disableTwoFactor() still runs its own
* requireConfirmedPassword() check, unchanged.
*/
class ConfirmDisableTwoFactor extends ModalComponent
{
public function confirm(): void
{
$this->dispatch('two-factor-disable-confirmed');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-disable-two-factor');
}
}

View File

@ -1,24 +0,0 @@
<?php
namespace App\Livewire\Admin;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before an operator signs every other browser out (R23). The
* console's own, because operators and customers are two identities (R21) and
* one shared modal would raise an event both sides listen for.
*/
class ConfirmEndOtherSessions extends ModalComponent
{
public function confirm(): void
{
$this->dispatch('sessions-end-others-confirmed');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-end-other-sessions');
}
}

View File

@ -1,39 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Services\Secrets\SecretVault;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a stored override is removed (R23), reverting the key
* to whatever the server file provides. No mutation here it dispatches
* back to Secrets, whose forget() is unchanged.
*/
class ConfirmForgetSecret extends ModalComponent
{
public string $key;
public string $label = '';
public function mount(string $key): void
{
$this->authorize('secrets.manage');
abort_if(! array_key_exists($key, SecretVault::REGISTRY), 404);
$this->key = $key;
$this->label = __(SecretVault::REGISTRY[$key]['label']);
}
public function confirm(): void
{
$this->authorize('secrets.manage');
$this->dispatch('secret-forget-confirmed', key: $this->key);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-forget-secret');
}
}

View File

@ -1,39 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\VpnPeer;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a staff access gets a fresh keypair (R23). The reissue
* itself still runs on Vpn::reissue(), unchanged this only gates it and,
* once confirmed, dispatches back so the page can show the new config.
*/
class ConfirmReissueVpnPeer extends ModalComponent
{
public string $uuid;
public string $name = '';
public function mount(string $uuid): void
{
$peer = VpnPeer::query()->where('uuid', $uuid)->firstOrFail();
$this->authorize('update', $peer);
abort_if($peer->kind !== VpnPeer::KIND_STAFF, 404);
$this->uuid = $uuid;
$this->name = $peer->name;
}
public function confirm(): void
{
$this->dispatch('vpn-peer-reissue-confirmed', uuid: $this->uuid);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-reissue-vpn-peer');
}
}

View File

@ -1,46 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Host;
use App\Provisioning\Jobs\PurgeHost;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for the destructive "remove host" action (R5). Deregisters the
* CluPilot record only it does not touch the physical server. The host is
* deactivated immediately and purged asynchronously so removal never blocks on
* (or races) an in-flight provisioning step.
*/
class ConfirmRemoveHost extends ModalComponent
{
public string $uuid;
public string $name = '';
public function mount(string $uuid): void
{
$this->uuid = $uuid;
$this->name = Host::query()->where('uuid', $uuid)->value('name') ?? '';
}
public function remove()
{
$this->authorize('hosts.manage');
$host = Host::query()->where('uuid', $this->uuid)->first();
if ($host !== null) {
// Deactivate now (out of placement, visibly removed), finalize on the
// provisioning worker which waits for the runner lock.
$host->update(['status' => 'disabled']);
PurgeHost::dispatch($host->uuid);
}
return $this->redirectRoute('admin.hosts', navigate: true);
}
public function render()
{
return view('livewire.admin.confirm-remove-host');
}
}

View File

@ -1,35 +0,0 @@
<?php
namespace App\Livewire\Admin;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before .env the file that holds every credential in the
* system is overwritten (R23).
*
* The content being written lives only in Integrations' own $envContent; this
* modal never sees it and cannot mutate anything itself. Confirming
* dispatches back to the page component, whose saveEnv() keeps its own
* guardSecrets() (secrets.manage + a recently confirmed password) unchanged
* same pattern as ConfirmSaveSecret/ConfirmForgetSecret.
*/
class ConfirmSaveEnv extends ModalComponent
{
public function mount(): void
{
$this->authorize('secrets.manage');
}
public function confirm(): void
{
$this->authorize('secrets.manage');
$this->dispatch('env-save-confirmed');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-save-env');
}
}

View File

@ -1,42 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Services\Secrets\SecretVault;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a credential takes effect (R23).
*
* The value being saved lives only in Secrets' own (deferred) `entered`
* property this modal never sees it and cannot mutate anything itself.
* Confirming dispatches back to the page component, whose save() keeps its
* own guard() (capability + recent password) unchanged.
*/
class ConfirmSaveSecret extends ModalComponent
{
public string $key;
public string $label = '';
public function mount(string $key): void
{
$this->authorize('secrets.manage');
abort_if(! array_key_exists($key, SecretVault::REGISTRY), 404);
$this->key = $key;
$this->label = __(SecretVault::REGISTRY[$key]['label']);
}
public function confirm(): void
{
$this->authorize('secrets.manage');
$this->dispatch('secret-save-confirmed', key: $this->key);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-save-secret');
}
}

View File

@ -1,112 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Customer;
use App\Models\PlanFamily;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.admin')]
class Customers extends Component
{
/** Suspend / reactivate a customer account (a guarded lifecycle action, not delete). */
public function toggleSuspend(string $uuid): void
{
$this->authorize('customers.manage');
$customer = Customer::query()->where('uuid', $uuid)->first();
if ($customer === null) {
return;
}
// A closed account is terminal — the suspend/reactivate toggle must not
// resurrect it to active while closed_at is still set.
if ($customer->closed_at !== null || $customer->status === 'closed') {
return;
}
$customer->update([
'status' => $customer->status === 'suspended' ? 'active' : 'suspended',
]);
$this->dispatch('notify', message: __('admin.customer_'.($customer->status === 'suspended' ? 'suspended' : 'reactivated')));
}
public function render()
{
$locale = app()->getLocale();
$plans = app(PlanCatalogue::class)->sellable();
$customers = Customer::query()
->with(['instances.subscription'])
->orderBy('name')
->get();
$rows = $customers->map(function (Customer $c) use ($plans, $locale) {
$instance = $c->instances->sortByDesc('id')->first();
$planKey = $instance->plan ?? null;
// What this customer actually pays, off their contract — not what
// the plan costs today. Otherwise a price rise inflates reported
// revenue for every grandfathered customer overnight.
//
// Per MONTH, whatever the term: a yearly contract stores the whole
// year, and adding that to a monthly column would report twelve
// times the revenue for anyone who paid up front.
$contract = $instance?->subscription;
$priceCents = (int) ($contract?->monthlyPriceCents()
?? ($planKey !== null ? ($plans[$planKey]['price_cents'] ?? 0) : 0));
return [
'uuid' => $c->uuid,
'name' => $c->name,
'plan' => $planKey !== null ? __('billing.plan.'.$planKey) : '—',
'mrr' => Number::currency($priceCents / 100, in: 'EUR', locale: $locale),
'instance' => $instance->subdomain ?? '—',
// Only an instance that exists can hand out an admin login.
'instance_uuid' => ($instance?->status === 'active') ? $instance->uuid : null,
'closed' => $c->closed_at !== null || $c->status === 'closed',
'suspended' => $c->status === 'suspended',
'status' => match (true) {
$c->closed_at !== null || $c->status === 'closed' => 'closed',
$c->status === 'suspended' => 'suspended',
default => $instance->status ?? $c->status ?? 'provisioning',
},
];
})->all();
// Plan distribution for the doughnut — derived from live instances, and
// keyed by what customers are actually ON, not by what is on sale. A
// withdrawn plan still has customers, and dropping them from the chart
// would quietly understate the estate.
$labels = [];
$counts = [];
foreach (PlanFamily::query()->orderBy('tier')->pluck('key') as $planKey) {
$n = $customers->filter(fn (Customer $c) => $c->instances->contains('plan', $planKey))->count();
if ($n > 0) {
$labels[] = __('billing.plan.'.$planKey);
$counts[] = $n;
}
}
return view('livewire.admin.customers', [
'rows' => $rows,
'plansChart' => [
'type' => 'doughnut',
'data' => [
'labels' => $labels,
'datasets' => [[
'data' => $counts,
'backgroundColor' => ['token:info', 'token:accent', 'token:success-bright', 'token:warning'],
'borderWidth' => 0,
]],
],
'options' => [
'cutout' => '62%',
'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]],
],
],
]);
}
}

View File

@ -1,79 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.admin')]
class Datacenters extends Component
{
#[Validate(['required', 'string', 'max:8', 'regex:/^[a-z0-9]([a-z0-9-]{0,6}[a-z0-9])?$/', 'unique:datacenters,code'])]
public string $code = '';
#[Validate('required|string|max:255')]
public string $name = '';
/**
* Which building, not which city fsn-dc-15. Optional, free text, and
* nothing keys off it: see EditDatacenter and the migration that adds it.
*
* Offered here as well as in the edit modal so that creating a datacenter
* and saying which one it is are not two separate errands.
*/
#[Validate('nullable|string|max:255')]
public string $facility = '';
// Country picked from config/countries.php (no manual code typos).
#[Validate('nullable|string|max:2')]
public string $location = '';
public function save(): void
{
$this->authorize('datacenters.manage');
// Normalize before validating so the unique check matches how the row is
// stored — otherwise `FSN` passes the rule but the lowercased insert collides.
$this->code = strtolower(trim($this->code));
// Country must be one of the curated list — a forged request can bypass
// the <select>, so enforce membership server-side (mirrors EditDatacenter).
$data = $this->validate([
// A datacenter code becomes a DNS label (fsn-01.node.clupilot.com),
// so it has to be one: lowercase, no underscores, no leading or
// trailing dash.
'code' => ['required', 'string', 'max:8', 'regex:/^[a-z0-9]([a-z0-9-]{0,6}[a-z0-9])?$/', 'unique:datacenters,code'],
'name' => 'required|string|max:255',
'facility' => 'nullable|string|max:255',
'location' => ['nullable', Rule::in(array_keys((array) config('countries')))],
]);
Datacenter::create([
'code' => $data['code'],
'name' => $data['name'],
'facility' => $data['facility'] ?: null,
'location' => $data['location'] ?: null,
'active' => true,
]);
$this->reset('code', 'name', 'facility', 'location');
$this->dispatch('notify', message: __('datacenters.created'));
}
public function toggle(string $uuid): void
{
$this->authorize('datacenters.manage');
$datacenter = Datacenter::query()->where('uuid', $uuid)->first();
$datacenter?->update(['active' => ! $datacenter->active]);
}
public function render()
{
return view('livewire.admin.datacenters', [
'datacenters' => Datacenter::query()->withCount('hosts')->orderBy('name')->get(),
'countries' => config('countries'),
]);
}
}

View File

@ -1,200 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use App\Models\Host;
use App\Models\Order;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Validate;
use LivewireUI\Modal\ModalComponent;
/**
* Edit a datacenter in a modal (avoids the row-height jump of inline editing).
* Country is picked from a list so it can't be mistyped.
*
* The code is editable, but only while nothing depends on it.
*
* It was fully immutable, which made a typo permanent. It cannot be freely
* mutable either, because it is not only a foreign key: hosts.datacenter
* references it (and MariaDB refuses the update outright, there being no ON
* UPDATE clause), every past order stores it as plain text, each host's DNS
* name was minted from it (fsn-01 a record that exists at the provider and
* does not move), the machine's own hostname was set from it while it was being
* built, and the per-code counter that hands out those numbers is keyed by it.
* Renaming a code with hosts in it is therefore not a rename; it is a datacenter
* called hel full of machines called fsn-01.
*
* So: free while nothing references it which is the case that matters, a
* typo noticed shortly after creating one and locked with the reason named
* once anything does.
*/
class EditDatacenter extends ModalComponent
{
public string $uuid = '';
public string $code = '';
#[Validate('required|string|max:255')]
public string $name = '';
/**
* Which building, not which city fsn-dc-15.
*
* The provider's own label, free text on purpose: it is theirs to change,
* and nothing here may key off it. Only so that picking "fsn" twice for
* redundancy is a decision somebody can actually make.
*/
#[Validate('nullable|string|max:255')]
public string $facility = '';
public string $location = '';
/** The location present when the modal opened — a pre-curation free-form value. */
public string $originalLocation = '';
/**
* Whether new hosts and orders may still be placed here.
*
* The column and the scope existed; the form did not offer it, so a
* datacenter could be created and never switched off again the one
* lifecycle action a location actually needs.
*/
public bool $active = true;
/** What references this code, and therefore what stops it being renamed. */
public int $hostCount = 0;
public int $orderCount = 0;
public function mount(string $uuid): void
{
$this->authorize('datacenters.manage'); // modals are reachable without the route middleware
$dc = Datacenter::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->code = $dc->code;
$this->name = $dc->name;
$this->facility = (string) $dc->facility;
$this->location = (string) $dc->location;
$this->originalLocation = (string) $dc->location;
$this->active = (bool) $dc->active;
$this->hostCount = $this->hostsIn($dc->code);
$this->orderCount = $this->ordersIn($dc->code);
}
/**
* May this code still be changed?
*
* Always answered from the database, never from a hydrated property: the
* lock is an authorisation decision, and a forged request that flipped it
* would take the DNS names of every host in this datacenter with it.
*/
public function codeIsFree(string $code): bool
{
return $this->hostsIn($code) === 0 && $this->ordersIn($code) === 0;
}
private function hostsIn(string $code): int
{
return Host::query()->where('datacenter', $code)->count();
}
private function ordersIn(string $code): int
{
return Order::query()->where('datacenter', $code)->count();
}
public function save()
{
$this->authorize('datacenters.manage');
// Accept the configured countries plus the record's own legacy value, so a
// datacenter created before the curated list can still be edited. The
// legacy value is read from the DB — never from the client-hydrated
// property, which could be forged to whitelist an arbitrary location.
$dc = Datacenter::query()->where('uuid', $this->uuid)->firstOrFail();
$allowed = array_keys((array) config('countries'));
if ($dc->location) {
$allowed[] = $dc->location;
}
// Recomputed here, never taken from the property the browser sent back:
// the lock is what protects every host's DNS name in this datacenter
// from being orphaned, and a forged request must not be able to lift it.
$codeIsFree = $this->codeIsFree($dc->code);
if ($codeIsFree) {
// Normalised before validating so the unique check matches how the
// row is stored — otherwise `FSN` passes the rule and the update
// then collides with an existing lowercased row (mirrors Datacenters).
$this->code = strtolower(trim($this->code));
} else {
// Whatever came back from the browser is discarded. Silently, and
// deliberately: the field was never offered, so there is nothing to
// report to an honest client, and a dishonest one gets no purchase.
$this->code = $dc->code;
}
$rules = [
'name' => 'required|string|max:255',
'facility' => 'nullable|string|max:255',
'location' => ['nullable', Rule::in($allowed)], // array form — a legacy value may contain commas
'active' => 'boolean',
];
if ($codeIsFree) {
// The same rule the create form uses: a code becomes a DNS label
// (fsn-01.node.clupilot.com), so it has to be one. Ignoring its own
// row, or saving without touching the code would collide with itself.
$rules['code'] = [
'required', 'string', 'max:8',
'regex:/^[a-z0-9]([a-z0-9-]{0,6}[a-z0-9])?$/',
Rule::unique('datacenters', 'code')->ignore($dc->id),
];
}
$data = $this->validate($rules);
// Only on the transition, and compared against what is STORED: an
// already-inactive location would otherwise announce that it had just
// been switched off every time its name was edited.
$justDeactivated = $dc->active && ! $data['active'];
$hostsLeftRunning = $justDeactivated ? $dc->hosts()->count() : 0;
Datacenter::query()->where('uuid', $this->uuid)->update([
// Only where it was actually offered. $data has no 'code' key at
// all when the field was locked, and writing $this->code back would
// be the same value anyway — but only by luck, and this is the line
// that would quietly stop being true if the lock ever moved.
...($codeIsFree ? ['code' => $data['code']] : []),
'name' => $data['name'],
'facility' => $data['facility'] ?: null,
'location' => $data['location'] ?: null,
'active' => $data['active'],
]);
// One message, not two. Both go through the same toast, and the second
// replaces the first — so the generic "saved" would swallow the only
// sentence that says the hosts there are still running.
$this->dispatch('notify', message: $hostsLeftRunning > 0
? __('datacenters.deactivated_with_hosts', ['n' => $hostsLeftRunning])
: __('datacenters.updated'));
return $this->redirectRoute('admin.datacenters', navigate: true);
}
public function render()
{
// Read again rather than shown from the mounted properties: a host can
// be onboarded into this datacenter while the modal sits open, and an
// input offered on the strength of a stale count would be refused on
// save with nothing on screen explaining why.
$stored = Datacenter::query()->where('uuid', $this->uuid)->value('code') ?? $this->code;
$this->hostCount = $this->hostsIn($stored);
$this->orderCount = $this->ordersIn($stored);
return view('livewire.admin.edit-datacenter', [
'countries' => config('countries'),
'codeFree' => $this->hostCount === 0 && $this->orderCount === 0,
]);
}
}

View File

@ -1,138 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\ExportTarget;
use App\Services\Secrets\SecretCipher;
use LivewireUI\Modal\ModalComponent;
/**
* Adding or changing one destination.
*
* The password never lives here. It goes into the secret vault under a key
* derived from the target, and only that key is stored on the row a
* credential in a settings table is a credential in every database dump. An
* empty password field on an existing target means "leave it alone", not
* "clear it": the field cannot show what is stored, so treating blank as a
* deletion would wipe the credential every time somebody renamed the target.
*/
class EditExportTarget extends ModalComponent
{
public string $uuid = '';
public string $name = '';
public string $driver = ExportTarget::LOCAL;
public string $path = '';
/** 'year' for an archive somebody keeps, 'day' for a handover point. */
public string $layout = ExportTarget::BY_YEAR;
/** Empty means keep for ever, which is the default and the safe one. */
public ?int $keepDays = null;
public string $host = '';
public ?int $port = 22;
public string $username = '';
public string $password = '';
public bool $hasPassword = false;
public bool $active = true;
public function mount(?string $uuid = null): void
{
$this->authorize('site.manage'); // modals bypass the route middleware
if ($uuid === null) {
return;
}
$target = ExportTarget::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->name = $target->name;
$this->driver = $target->driver;
$this->path = $target->path;
$this->layout = $target->layout;
$this->keepDays = $target->keep_days;
$this->host = (string) $target->host;
$this->port = $target->port ?: 22;
$this->username = (string) $target->username;
$this->active = (bool) $target->active;
$this->hasPassword = trim((string) $target->secret_key) !== '';
}
public function save()
{
$this->authorize('site.manage');
$rules = [
'name' => 'required|string|max:120',
'driver' => 'required|in:local,sftp',
'path' => 'required|string|max:255',
'layout' => 'required|in:year,day',
// Nothing, or a real number of days. Zero would read as "delete
// immediately" to somebody who typed it meaning "never".
'keepDays' => 'nullable|integer|min:1|max:3650',
'active' => 'boolean',
];
if ($this->driver === ExportTarget::SFTP) {
$rules += [
'host' => 'required|string|max:190',
'port' => 'required|integer|min:1|max:65535',
'username' => 'required|string|max:120',
// Required only when there is nothing stored yet. Blank on an
// existing target means "unchanged" — the field cannot show
// what is stored, so treating blank as a deletion would wipe
// the credential on every unrelated edit.
'password' => $this->hasPassword ? 'nullable|string|max:255' : 'required|string|max:255',
];
}
$data = $this->validate($rules);
$target = $this->uuid !== ''
? ExportTarget::query()->where('uuid', $this->uuid)->firstOrFail()
: new ExportTarget;
$target->fill([
'name' => $data['name'],
'driver' => $data['driver'],
'path' => rtrim($data['path'], '/') ?: '/',
'layout' => $data['layout'],
'keep_days' => $data['keepDays'] ?: null,
'host' => $this->driver === ExportTarget::SFTP ? $this->host : null,
'port' => $this->driver === ExportTarget::SFTP ? $this->port : null,
'username' => $this->driver === ExportTarget::SFTP ? $this->username : null,
'active' => $data['active'] ?? true,
]);
$target->save();
// Encrypted onto the row rather than into the vault. The vault keeps a
// registry of fixed, documented credentials and refuses anything else,
// which is right for it — a per-destination password is not one of
// those, and forcing it in would make the registry meaningless. Same
// cipher, same key, so it is no more readable in a dump than the rest.
if ($this->driver === ExportTarget::SFTP && $this->password !== '') {
$target->forceFill([
'secret_key' => app(SecretCipher::class)->encrypt($this->password),
])->save();
}
$this->dispatch('notify', message: __('finance.targets_saved'));
return $this->redirectRoute('admin.finance', navigate: true);
}
public function render()
{
return view('livewire.admin.edit-export-target');
}
}

View File

@ -1,123 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\InvoiceSeries;
use Illuminate\Validation\Rule;
use LivewireUI\Modal\ModalComponent;
/**
* Editing a Rechnungskreis, with the parts that cannot move held still.
*
* The same shape as EditDatacenter and for the same reason: some fields stop
* being editable once something depends on them. Here the something is a
* document in somebody's accounts.
*
* The prefix is part of every number already issued. Changing it from RE to AR
* does not rename anything it leaves a series whose documents say RE-2026-
* and whose next document says AR-2026-, with no record that they belong
* together. Free while the series is empty, which is the case that matters: a
* prefix chosen wrongly on the day the shop opens.
*
* The counter may be raised but never lowered. Lowering it re-issues a number
* that is already printed on a document somebody holds, and gapless-and-
* ascending is a legal requirement rather than a preference.
*/
class EditInvoiceSeries extends ModalComponent
{
public string $uuid = '';
public string $name = '';
public string $prefix = '';
public int $digits = 4;
public bool $yearlyReset = true;
public int $nextNumber = 1;
public bool $active = true;
/** How many documents this series has already issued. */
public int $issued = 0;
/** The lowest number this counter may still be set to. */
public int $floor = 1;
public function mount(string $uuid): void
{
$this->authorize('site.manage'); // modals are reachable without the route middleware
$series = InvoiceSeries::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->name = $series->name;
$this->prefix = $series->prefix;
$this->digits = (int) $series->digits;
$this->yearlyReset = (bool) $series->yearly_reset;
$this->nextNumber = (int) $series->next_number;
$this->active = (bool) $series->active;
$this->issued = $series->invoices()->count();
$this->floor = (int) $series->next_number;
}
public function save()
{
$this->authorize('site.manage');
$series = InvoiceSeries::query()->where('uuid', $this->uuid)->firstOrFail();
// Recomputed from the database, never read back from the properties the
// browser returns: these two rules are what keep already-issued numbers
// from being re-used, and a forged field must not lift either.
$issued = $series->invoices()->count();
$floor = (int) $series->next_number;
$rules = [
'name' => 'required|string|max:120',
'digits' => 'required|integer|min:3|max:10',
'active' => 'boolean',
'nextNumber' => ['required', 'integer', 'min:'.$floor],
];
if ($issued === 0) {
$rules['prefix'] = [
'required', 'string', 'max:12', 'regex:/^[A-Z0-9-]+$/',
Rule::unique('invoice_series', 'prefix')->ignore($series->id),
];
// Only while nothing has been issued: switching an established
// series between yearly and continuous puts a gap in one of them.
$rules['yearlyReset'] = 'boolean';
}
if ($issued === 0) {
$this->prefix = strtoupper(trim($this->prefix));
} else {
$this->prefix = $series->prefix;
$this->yearlyReset = (bool) $series->yearly_reset;
}
$data = $this->validate($rules);
$series->update([
'name' => $data['name'],
'digits' => $data['digits'],
'next_number' => $data['nextNumber'],
'active' => $data['active'],
...($issued === 0 ? [
'prefix' => $data['prefix'],
'yearly_reset' => $data['yearlyReset'],
] : []),
]);
$this->dispatch('notify', message: __('finance.series_saved'));
return $this->redirectRoute('admin.finance', navigate: true);
}
public function render()
{
return view('livewire.admin.edit-invoice-series');
}
}

View File

@ -1,209 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\ExportTarget;
use App\Models\Invoice;
use App\Models\InvoiceExport;
use App\Models\InvoiceSeries;
use App\Services\Deployment\UpdateChannel;
use App\Support\CompanyProfile;
use App\Support\Settings;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithFileUploads;
/**
* Everything an invoice needs before one can be issued.
*
* Its own tab rather than a section of Settings: the company's registered
* details, the VAT rate and the Rechnungskreise are not preferences, they are
* what appears on a legal document, and burying them under "Einstellungen"
* beside the site-visibility switch invites somebody to change one in passing.
*
* None of it reaches an invoice that already exists. Every value is copied into
* the invoice when its number is assigned, so an address corrected here is
* correct from the next document onwards and cannot rewrite an old one.
*/
#[Layout('layouts.admin')]
class Finance extends Component
{
use WithFileUploads;
/** @var array<string, mixed> */
public array $company = [];
public float $taxRate = 20.0;
/**
* A freshly minted collection key, shown once and never stored.
*
* Held in the component for the length of one page view. The private half
* exists to be copied into a NAS and nowhere else writing it into the
* database "for convenience" would put a working credential in every backup
* of that database.
*
* @var array<string, string>|null
*/
public ?array $archiveKey = null;
/** True between asking the host for a key and the host answering. */
public bool $waitingForKey = false;
/** New logo upload, validated on save. */
public $logo = null;
public string $logoPath = '';
public function mount(): void
{
$this->authorize('site.manage');
$this->company = CompanyProfile::all();
$this->logoPath = (string) ($this->company['logo_path'] ?? '');
$this->taxRate = CompanyProfile::taxRate();
}
public function saveCompany(): void
{
$this->authorize('site.manage');
$data = $this->validate([
'company.name' => 'required|string|max:190',
'company.address' => 'required|string|max:190',
'company.postcode' => 'required|string|max:20',
'company.city' => 'required|string|max:120',
'company.country' => 'required|string|max:120',
'company.phone' => 'nullable|string|max:60',
'company.email' => 'nullable|email|max:190',
'company.website' => 'nullable|string|max:190',
'company.register_number' => 'nullable|string|max:60',
'company.register_court' => 'nullable|string|max:120',
'company.vat_id' => 'required|string|max:40',
'company.bank_name' => 'nullable|string|max:120',
'company.iban' => 'nullable|string|max:42',
'company.bic' => 'nullable|string|max:15',
'company.payment_days' => 'required|integer|min:0|max:180',
'company.payment_terms' => 'nullable|string|max:500',
// PNG or WEBP only, and small. A PDF embeds whatever it is given,
// and a four-megabyte photograph would be embedded in every invoice
// ever rendered from then on.
'logo' => 'nullable|image|mimes:png,webp|max:1024',
'taxRate' => 'required|numeric|min:0|max:100',
]);
if ($this->logo !== null) {
$previous = $this->logoPath;
$this->logoPath = $this->logo->store('company', 'public');
$this->company['logo_path'] = $this->logoPath;
// The old file goes only once the new one is stored. The other
// order loses the logo entirely if the upload fails, and every
// invoice rendered afterwards is missing it.
if ($previous !== '' && $previous !== $this->logoPath) {
Storage::disk('public')->delete($previous);
}
$this->logo = null;
}
CompanyProfile::put($data['company']);
Settings::set('company.tax_rate', (float) $data['taxRate']);
$this->dispatch('notify', message: __('finance.company_saved'));
}
/**
* Ask the host to mint an SSH key that may only read this archive.
*
* Everything an operator otherwise does by hand across three machines
* generate a keypair, install the public half restricted to one directory,
* carry the private half to the NAS. None of it can happen here: the panel
* is www-data in a container and the keys, the home directory and rrsync
* are all on the host. So it asks, and the agent does it.
*/
public function createPullAccess(string $uuid): void
{
$this->authorize('site.manage');
$target = ExportTarget::query()->where('uuid', $uuid)->firstOrFail();
if ($target->driver !== ExportTarget::LOCAL) {
// Only a directory on this host has a home directory and an
// authorized_keys to put anything in. A destination somewhere else
// is somebody else's machine.
$this->dispatch('notify', message: __('finance.pull_local_only'));
return;
}
$accepted = app(UpdateChannel::class)->requestArchiveKey(
(string) (auth('operator')->user()?->email ?? ''),
$target->path,
'clupilot-archiv-'.$target->uuid,
);
$this->archiveKey = null;
$this->waitingForKey = $accepted;
$this->dispatch('notify', message: __($accepted
? 'finance.pull_requested'
: 'admin_settings.update_already_requested'));
}
/** Polled while waiting. Takes the key the moment the host has left one. */
public function collectArchiveKey(): void
{
$this->authorize('site.manage');
if (! $this->waitingForKey) {
return;
}
$key = app(UpdateChannel::class)->takeArchiveKey();
if ($key !== null) {
$this->archiveKey = $key;
$this->waitingForKey = false;
}
}
public function removeLogo(): void
{
$this->authorize('site.manage');
if ($this->logoPath !== '') {
Storage::disk('public')->delete($this->logoPath);
}
$this->logoPath = '';
$this->company['logo_path'] = '';
CompanyProfile::put(['logo_path' => '']);
$this->dispatch('notify', message: __('finance.logo_removed'));
}
public function render()
{
return view('livewire.admin.finance', [
'series' => InvoiceSeries::query()->orderBy('kind')->get(),
// What is still missing before an invoice may be issued at all. An
// invoice without a name, an address or a VAT number is not a valid
// invoice here, and issuing one is worse than refusing to.
'missing' => CompanyProfile::missingForInvoicing(),
// Counted from the join table, never by asking the destinations.
// A stat() on a network mount that has gone away blocks for the
// mount's whole timeout, and this is a page an operator opens when
// something is already wrong.
'targets' => ExportTarget::query()->orderBy('name')->get()->map(fn (ExportTarget $t) => [
'model' => $t,
'pending' => Invoice::query()->whereDoesntHave('exports', fn ($q) => $q
->where('export_target_id', $t->id)->whereNotNull('exported_at'))->count(),
'failed' => InvoiceExport::query()->where('export_target_id', $t->id)
->whereNotNull('error')->whereNull('exported_at')->count(),
]),
]);
}
}

View File

@ -1,46 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Actions\StartHostOnboarding;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.admin')]
class HostCreate extends Component
{
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|string|exists:datacenters,code,active,1')]
public string $datacenter = '';
public function mount(): void
{
$this->datacenter = (string) \App\Models\Datacenter::query()->active()->orderBy('name')->value('code');
}
#[Validate('required|ip|unique:hosts,public_ip')]
public string $public_ip = '';
#[Validate('required|string|min:8')]
public string $root_password = '';
public function save(StartHostOnboarding $action)
{
$this->authorize('hosts.manage');
$data = $this->validate();
$host = $action->run($data);
return $this->redirectRoute('admin.hosts.show', ['host' => $host->uuid], navigate: true);
}
public function render()
{
return view('livewire.admin.host-create', [
'datacenters' => \App\Models\Datacenter::query()->active()->orderBy('name')->get(),
]);
}
}

View File

@ -1,117 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Support\Collection;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.admin')]
class HostDetail extends Component
{
public Host $host;
public function mount(Host $host): void
{
$this->host = $host;
}
/** Live refresh whenever any run advances (admins-only channel). */
#[On('echo-private:admin.runs,StepAdvanced')]
public function onStepAdvanced(): void
{
$this->host->refresh();
}
/** Adjust the capacity reserve (% of storage kept free for headroom). */
public function saveReserve(int $reserve): void
{
$this->authorize('hosts.manage');
$reserve = max(0, min(90, $reserve));
$this->host->update(['reserve_pct' => $reserve]);
$this->dispatch('notify', message: __('hosts.detail.reserve_saved'));
}
/**
* Drain / return a host: toggle between active and disabled. Disabled takes
* it out of placement (maintenance) without purging it distinct from the
* destructive "remove host". Never touches a host mid-onboarding.
*/
public function toggleMaintenance(): void
{
$this->authorize('hosts.manage');
if ($this->host->status === 'active') {
$this->host->update(['status' => 'disabled']);
} elseif ($this->host->status === 'disabled') {
$this->host->update(['status' => 'active']);
}
}
public function retry(): void
{
$this->authorize('hosts.manage');
$run = $this->currentRun();
if ($run !== null && $run->status === ProvisioningRun::STATUS_FAILED) {
$run->update([
'status' => ProvisioningRun::STATUS_RUNNING,
'attempt' => 0,
'next_attempt_at' => now(),
'started_at' => now(), // reset the step timer so it doesn't re-time-out instantly
'error' => null,
]);
$this->host->update(['status' => 'onboarding']);
AdvanceRunJob::dispatch($run->uuid);
}
}
private function currentRun(): ?ProvisioningRun
{
return $this->host->runs()->latest('id')->first();
}
/** @return array<int, array{label: string, state: string}> */
private function buildSteps(?ProvisioningRun $run): array
{
$pipeline = config('provisioning.pipelines.host', []);
$current = $run?->current_step ?? 0;
$status = $run?->status;
$steps = [];
foreach ($pipeline as $index => $class) {
if ($status === ProvisioningRun::STATUS_COMPLETED || $index < $current) {
$state = 'done';
} elseif ($index === $current) {
$state = $status === ProvisioningRun::STATUS_FAILED ? 'failed' : 'running';
} else {
$state = 'pending';
}
$steps[] = ['label' => __(app($class)->label()), 'state' => $state];
}
return $steps;
}
public function render()
{
$run = $this->currentRun();
/** @var Collection $events */
$events = $run
? $run->events()->latest('id')->limit(30)->get()
: collect();
return view('livewire.admin.host-detail', [
'run' => $run,
'steps' => $this->buildSteps($run),
'events' => $events,
'instances' => $this->host->instances()->latest('id')->get(),
'health' => $this->host->healthState(),
]);
}
}

View File

@ -1,58 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use App\Models\Host;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.admin')]
class Hosts extends Component
{
#[Url(as: 'q')]
public string $search = '';
#[Url]
public string $datacenter = '';
#[Url]
public string $status = '';
public function updated(): void
{
// no-op; keeps the query string in sync as filters change
}
public function clearFilters(): void
{
$this->reset('search', 'datacenter', 'status');
}
public function render()
{
$hosts = Host::query()
->withCount('instances')
// The VPN column reads SyncVpnPeers' synced row, not a live hub
// call — the console container has no wg0 (see VpnPeer::vpnPeer()
// and SyncVpnPeers' docblock). One eager-loaded query for the
// whole page, not one per host.
->with('vpnPeer')
->when($this->search !== '', function ($q) {
$term = '%'.$this->search.'%';
$q->where(fn ($w) => $w->where('name', 'like', $term)->orWhere('public_ip', 'like', $term));
})
->when($this->datacenter !== '', fn ($q) => $q->where('datacenter', $this->datacenter))
->when($this->status !== '', fn ($q) => $q->where('status', $this->status))
->orderBy('datacenter')->orderBy('name')
->get();
return view('livewire.admin.hosts', [
'hosts' => $hosts,
'datacenters' => Datacenter::query()->orderBy('name')->get(),
'statuses' => ['pending', 'onboarding', 'active', 'error', 'disabled'],
'total' => Host::query()->count(),
]);
}
}

View File

@ -1,114 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\ResolvesOperator;
use App\Models\Instance;
use App\Provisioning\Jobs\IssueInstanceAdminAccess;
use App\Services\Wireguard\ConfigHandoff;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use LivewireUI\Modal\ModalComponent;
/**
* Administrator access to a customer's Nextcloud.
*
* Not impersonation: that borrows a portal session. This resets our managed
* admin account inside the customer's installation and hands the credentials
* over once the only thing stock Nextcloud offers, and honest about what it
* does rather than pretending to be a passwordless jump.
*
* The operator's own password is required, every time. Taking control of a
* customer's installation is not something an unattended browser should be able
* to do on its own.
*/
class InstanceAdminAccess extends ModalComponent
{
use ResolvesOperator;
public string $uuid;
public string $subdomain = '';
public string $password = '';
/** Opaque handle; the credentials never enter the component snapshot. */
public ?string $token = null;
public bool $waiting = false;
public function mount(string $uuid): void
{
$this->authorize('instances.adminlogin');
$instance = Instance::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->subdomain = $instance->subdomain;
}
public function hydrate(): void
{
$this->authorize('instances.adminlogin');
}
public function request(): void
{
$this->authorize('instances.adminlogin');
$this->resetErrorBag('password');
if (! $operator = $this->currentOperator()) {
return;
}
$key = 'instance-admin:'.$operator->id;
if (RateLimiter::tooManyAttempts($key, 5)) {
$this->addError('password', __('instances.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]));
return;
}
if (! Hash::check($this->password, $operator->password)) {
RateLimiter::hit($key, 300);
$this->addError('password', __('instances.wrong_password'));
return;
}
RateLimiter::clear($key);
$this->reset('password');
// The reset runs on the provisioning worker — it owns the tunnel. The
// token is where it will leave the result.
$this->token = Str::random(40);
$this->waiting = true;
IssueInstanceAdminAccess::dispatch($this->uuid, $this->token, $operator->id);
}
public function render()
{
$payload = null;
if ($this->token !== null) {
$raw = ConfigHandoff::get($this->token);
if ($raw !== null) {
// Consumed on the first read: leaving it in the cache would make
// "shown once" a figure of speech — any replayed component
// request could fetch the password again for ten minutes. The
// payload lives in a local variable, so it reaches the view and
// nothing else.
ConfigHandoff::forget($this->token);
$this->token = null;
$this->waiting = false;
$payload = json_decode($raw, true);
}
}
return view('livewire.admin.instance-admin-access', [
'credentials' => isset($payload['password']) ? $payload : null,
'failed' => isset($payload['error']) ? $payload['error'] : null,
]);
}
}

View File

@ -1,50 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Instance;
use Illuminate\Support\Facades\Lang;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
/**
* Every instance on the estate, from the instances table.
*
* This page used to list seven invented instances on four invented hosts,
* complete with a Nextcloud version column. The version is not recorded
* anywhere, so the column is gone rather than filled in with something
* plausible and the storage column now shows the quota that was actually
* sold, not a made-up "used of total", because used disk is not collected.
*/
#[Layout('layouts.admin')]
class Instances extends Component
{
use WithPagination;
public function render()
{
$instances = Instance::query()
->with(['customer', 'host'])
->orderByDesc('id')
->paginate(25);
return view('livewire.admin.instances', [
'instances' => $instances,
'rows' => $instances->getCollection()->map(fn (Instance $i) => [
'address' => $i->custom_domain ?: $i->subdomain,
'customer' => $i->customer?->name ?? '—',
'host' => $i->host?->name ?? '—',
'vmid' => $i->vmid ?? '—',
'plan' => $i->plan !== null ? __('billing.plan.'.$i->plan) : '—',
'quota' => $i->quota_gb !== null ? $i->quota_gb.' GB' : '—',
'status' => $status = $i->status ?? 'provisioning',
// A status the lifecycle adds later must show as itself, never
// as "admin.status.whatever" in front of the owner.
'status_label' => Lang::has('admin.status.'.$status)
? __('admin.status.'.$status)
: $status,
])->all(),
]);
}
}

View File

@ -1,402 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\ConfirmsPassword;
use App\Services\Deployment\UpdateChannel;
use App\Services\Env\EnvFileEditor;
use App\Services\Env\InvalidEnvContentException;
use App\Services\Secrets\SecretVault;
use App\Support\ProvisioningSettings;
use App\Support\Settings;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use Throwable;
/**
* Connected services, one page, grouped by what they are for Zahlungen,
* DNS, Monitoring, VPN/WireGuard, SSH-Identität not by which of the two
* mechanisms below happens to hold a given value.
*
* Replaces Admin\Secrets and Admin\Infrastructure, which is what they were:
* one console area split across two pages by storage mechanism, reported as
* exactly the wrong axis to split on. An operator setting up an integration
* should not have to know whether a given field happens to be encrypted.
*
* Both mechanisms are unchanged underneath this is a presentation change,
* not a data migration:
*
* - SecretVault: a curated, encrypted key/value store for credentials that
* actually stop the business when they leak or expire. Gated by
* `secrets.manage` (Owner only) AND a recently confirmed password the
* realistic threat is an unlocked machine, not a stranger. A stored value
* is write-only: the page only ever shows an outline of it.
* - App\Support\Settings: plain deployment configuration. Gated by
* `hosts.manage` (Owner + Admin) alone none of it opens anything by
* itself. Shows its value in full, because there is nothing to protect.
*
* mount() is reachable with EITHER capability, because the two halves are no
* longer two pages an operator's role happens to see one, both, or neither
* of they are sections of the SAME page now. Each section, and each save
* action, still checks its OWN capability (guardSecrets()/guardInfra())
* server-side; an Admin who can reach this page for the DNS zone still gets
* 403 the moment they try to touch a vault entry.
*
* The .env editor (Part B) shares the secrets lock: it needs `secrets.manage`
* and the same confirmed password, because it is the one place on this page
* that can reach every credential the vault deliberately keeps write-only
* see EnvFileEditor for the safeguards that make that survivable.
*/
#[Layout('layouts.admin')]
class Integrations extends Component
{
use ConfirmsPassword {
forgetPasswordConfirmation as private lockAgain;
}
// Vault entries — same shape ConfirmSaveSecret/ConfirmForgetSecret expect
// and dispatch back into (secret-save-confirmed / secret-forget-confirmed).
public array $entered = [];
public ?array $check = null;
// Plain settings — App\Support\ProvisioningSettings' full list.
public string $dnsZone = '';
public string $wgEndpoint = '';
public string $wgHubPubkey = '';
public string $traefikDynamicPath = '';
public string $sshPublicKey = '';
public string $monitoringUrl = '';
// .env editor.
public string $envContent = '';
/**
* Loaded lazily, on the first render after unlocking never in mount(),
* so a merely-reachable page never pulls the whole credential file into
* component state for an operator who has not confirmed anything yet.
*/
public bool $envLoaded = false;
/**
* True from the moment saveEnv() successfully asks the agent to restart
* the workers, until render() observes the request is no longer pending.
*
* Drives the small inline "wird neu gestartet" indicator on this page
* not the full-screen maintenance overlay in layouts/admin.blade.php,
* which is for an actual deployment. A worker restart is four containers
* coming back in a few seconds, this page (and the console around it)
* never goes offline for it, and `app` the container answering this
* very poll is never among them. wire:poll on this page's own
* component is therefore enough to watch it finish; nothing here needs
* the client-side watcher an in-flight deployment does.
*/
public bool $envRestartWatching = false;
protected function confirmationGuard(): string
{
return 'operator';
}
public function mount(): void
{
abort_unless(Gate::any(['hosts.manage', 'secrets.manage']), 403);
if (Gate::allows('hosts.manage')) {
$this->dnsZone = ProvisioningSettings::dnsZone();
$this->wgEndpoint = ProvisioningSettings::wgEndpoint();
$this->wgHubPubkey = ProvisioningSettings::wgHubPublicKey();
$this->traefikDynamicPath = ProvisioningSettings::traefikDynamicPath();
$this->sshPublicKey = ProvisioningSettings::sshPublicKey();
$this->monitoringUrl = ProvisioningSettings::monitoringUrl();
}
}
// ---- Plain settings — App\Support\Settings, hosts.manage, no password. ----
public function saveInfra(): void
{
$this->guardInfra();
$data = $this->validate([
'dnsZone' => ['nullable', 'string', 'max:255'],
'wgEndpoint' => ['nullable', 'string', 'max:255'],
'wgHubPubkey' => ['nullable', 'string', 'max:255'],
'traefikDynamicPath' => ['nullable', 'string', 'max:255'],
'sshPublicKey' => ['nullable', 'string', 'max:1000'],
'monitoringUrl' => ['nullable', 'url', 'max:255'],
]);
Settings::set('provisioning.dns_zone', trim((string) $data['dnsZone']));
Settings::set('provisioning.wg_endpoint', trim((string) $data['wgEndpoint']));
Settings::set('provisioning.wg_hub_pubkey', trim((string) $data['wgHubPubkey']));
Settings::set('provisioning.traefik_dynamic_path', trim((string) $data['traefikDynamicPath']));
Settings::set('provisioning.ssh_public_key', trim((string) $data['sshPublicKey']));
Settings::set('monitoring.api_url', trim((string) $data['monitoringUrl']));
$this->dispatch('notify', message: __('integrations.settings_saved'));
}
// ---- Vault entries — SecretVault, secrets.manage + confirmed password. ----
public function save(string $key): void
{
$this->guardSecrets();
$field = self::field($key);
$value = trim((string) ($this->entered[$field] ?? ''));
if ($value === '') {
$this->addError('entered.'.$field, __('secrets.empty'));
return;
}
try {
app(SecretVault::class)->put($key, $value, Auth::guard('operator')->user());
} catch (Throwable $e) {
$this->addError('entered.'.$field, $e->getMessage());
return;
}
$this->entered[$field] = '';
$this->check = null;
$this->dispatch('notify', message: __('secrets.saved'));
}
/** ConfirmSaveSecret dispatches this back (R23) — see that class. */
#[On('secret-save-confirmed')]
public function onSaveConfirmed(string $key): void
{
$this->save($key);
}
public function forget(string $key): void
{
$this->guardSecrets();
app(SecretVault::class)->forget($key);
$this->check = null;
$this->dispatch('notify', message: __('secrets.removed'));
}
/** ConfirmForgetSecret dispatches this back (R23) — see that class. */
#[On('secret-forget-confirmed')]
public function onForgetConfirmed(string $key): void
{
$this->forget($key);
}
public function test(string $key): void
{
$this->guardSecrets();
$checker = SecretVault::REGISTRY[$key]['check'] ?? null;
abort_if($checker === null, 404);
$candidate = trim((string) ($this->entered[self::field($key)] ?? '')) ?: null;
$this->check = app($checker)->run($candidate);
}
/** The dotless form key for a registry key (a dot means nesting to Livewire). */
public static function field(string $key): string
{
return str_replace('.', '_', $key);
}
// ---- The .env editor — Part B. secrets.manage + confirmed password. ----
/**
* Validate, back up, write then do the two things saving this file used
* to leave as homework: clear the config cache (this container can do
* that itself, no host privileges needed) and ask the host-side agent to
* restart queue, queue-provisioning, scheduler and reverb (UpdateChannel
* it cannot be done from in here; see that class for why).
*
* EnvFileEditor does the write itself and the actual refusing; this only
* translates its one exception into a form error instead of a 500.
*/
public function saveEnv(): void
{
$this->guardSecrets();
try {
$backup = app(EnvFileEditor::class)->write($this->envContent);
} catch (InvalidEnvContentException $e) {
$this->addError('envContent', $e->invalidLine !== null
? __('integrations.env_invalid', ['line' => $e->invalidLine])
: __('integrations.env_empty'));
return;
}
// Unconditional, and deliberately not gated on
// app()->configurationIsCached() first: Illuminate's own ConfigClear
// command is already exactly that guard — it deletes the cache file
// if one exists and does nothing if there is none, so adding a check
// here would only save a single is_file() call in the common case at
// the cost of a second source of truth to keep in sync with it. This
// dev machine has no cache right now (checked: no
// bootstrap/cache/config.php), but deploy/update.sh and
// deploy/install-agent.sh both run `config:cache` as standard
// practice, so a real installation usually does — and while one is
// active, Laravel skips loading .env on every request AT ALL
// (LoadEnvironmentVariables bootstraps straight from the cached
// values instead). Left uncleared there, the value just written would
// stay invisible — not merely to the four workers below, to this
// application too — until the cache was rebuilt by hand.
Artisan::call('config:clear');
$channel = app(UpdateChannel::class);
$agentAlive = $channel->state()['agent_seen'];
$by = Auth::guard('operator')->user()?->email ?? 'console';
// '' means there was no previous file to protect — EnvFileEditor's
// own docblock explains why that is not an error.
$backupName = $backup !== '' ? basename($backup) : __('integrations.env_no_previous_file');
if (! $agentAlive) {
// Honest, not silent: the values ARE saved, but nothing is going
// to pick them up on its own. Pretending otherwise here is worse
// than the manual-restart card this replaces — see
// admin_settings.update_no_agent for the same shape on updates.
$this->envRestartWatching = false;
$this->dispatch('notify', message: __('integrations.env_saved_no_agent', ['backup' => $backupName]));
return;
}
if (! $channel->requestRestart($by)) {
// The single request slot is already taken by something else
// (a check, an update, or another restart just asked for) — rare,
// but real, and not something to paper over with a message that
// says "restarting" when nothing was actually queued.
$this->envRestartWatching = false;
$this->dispatch('notify', message: __('integrations.env_saved_restart_busy', ['backup' => $backupName]));
return;
}
$this->envRestartWatching = true;
$this->dispatch('notify', message: __('integrations.env_saved_restarting', ['backup' => $backupName]));
}
/** ConfirmSaveEnv dispatches this back (R23) — see that class. */
#[On('env-save-confirmed')]
public function onEnvSaveConfirmed(): void
{
$this->saveEnv();
}
/**
* Overrides ConfirmsPassword's own method (aliased above to lockAgain) to
* also drop the Stripe check result the render()-side check just below
* handles $envContent/$envLoaded, and handles it for BOTH ways a session
* can end up locked: this explicit click, and the confirmation window
* simply expiring with nobody clicking anything. A clear here alone would
* only ever cover the first.
*/
public function forgetPasswordConfirmation(): void
{
$this->lockAgain();
$this->check = null;
}
private function guardInfra(): void
{
$this->authorize('hosts.manage');
}
/** Capability AND a recently confirmed password, on every vault/.env action. */
private function guardSecrets(): void
{
$this->authorize('secrets.manage');
abort_unless($this->passwordRecentlyConfirmed(), 403);
}
public function render()
{
$vault = app(SecretVault::class);
$canSecrets = Gate::allows('secrets.manage');
$canInfra = Gate::allows('hosts.manage');
$unlocked = $this->passwordRecentlyConfirmed();
// Loaded here rather than in mount(): a page that is merely reachable
// must not already hold the whole credential file in component state
// for an operator who has not confirmed a password this session.
if ($canSecrets && $unlocked && ! $this->envLoaded) {
$this->envContent = app(EnvFileEditor::class)->read();
$this->envLoaded = true;
}
// The mirror case, and the one forgetPasswordConfirmation() alone
// does not cover: the confirmation window can also expire on its
// own, with nobody clicking "Wieder sperren" — $unlocked simply goes
// false on whatever the next render happens to be. Without this, the
// textarea disappears from the page but $envContent — the ENTIRE
// credential file — stays sitting in the component snapshot, reachable
// to anyone with the browser this was left open on (Codex review,
// P1). Checked on every render, not only the explicit lock action.
if (! $unlocked && $this->envLoaded) {
$this->envContent = '';
$this->envLoaded = false;
}
$restart = app(UpdateChannel::class)->state();
// The transition the small inline indicator exists to show: a restart
// THIS component asked for is no longer pending. Read off
// 'requested_at' rather than 'restarting': the latter is gated on
// agent_seen (see UpdateChannel::state()), so if the agent went
// quiet in the few seconds this was being waited on, 'restarting'
// would already read false EVEN THOUGH THE REQUEST FILE IS STILL
// SITTING THERE, unread — and this would announce "restarted"
// for a restart that never ran. 'requested_at' answers the only
// question that matters here — is a request still on disk — with no
// opinion about the agent either way. Fires once — the instant it
// does, envRestartWatching drops so the next poll (or the one after,
// on a slower host with no on-demand wake) does not dispatch it
// again. wire:poll on the .env section is what keeps calling
// render() while this is being waited on; see $envRestartWatching's
// own docblock for why that is enough and the full-page deployment
// overlay is not needed here.
if ($this->envRestartWatching && $restart['requested_at'] === null) {
$this->envRestartWatching = false;
$this->dispatch('notify', message: __('integrations.env_restart_done'));
}
return view('livewire.admin.integrations', [
'canSecrets' => $canSecrets,
'canInfra' => $canInfra,
'unlocked' => $unlocked,
'usable' => $vault->isUsable(),
'restart' => $restart,
'entries' => collect(SecretVault::REGISTRY)
->map(fn (array $meta, string $key) => [
'key' => $key,
'field' => self::field($key),
'label' => __($meta['label']),
'envKey' => $meta['env_key'],
'testable' => isset($meta['check']),
'source' => $vault->source($key),
'outline' => $unlocked ? $vault->outline($key) : null,
'updated_at' => $unlocked ? $vault->updatedAt($key) : null,
// The SSH identity is a multi-line PEM key: a single-line
// password field would mangle it on paste.
'multiline' => $key === 'ssh.private_key',
])
->keyBy('key'),
]);
}
}

View File

@ -1,80 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Invoice;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
/**
* Every document that has been issued, newest first.
*
* Read-only by design, and that is the feature. An issued invoice cannot be
* edited or deleted here or anywhere else a wrong one is corrected by issuing
* a cancellation and a new document, which is the only lawful way to correct
* one. There is no edit button to explain the absence of.
*/
#[Layout('layouts.admin')]
class Invoices extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $year = '';
public function mount(): void
{
$this->authorize('site.manage');
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedYear(): void
{
$this->resetPage();
}
public function render()
{
$invoices = Invoice::query()
->with(['customer', 'series'])
->when($this->search !== '', function ($q) {
$term = '%'.$this->search.'%';
$q->where(fn ($w) => $w->where('number', 'like', $term)
->orWhereHas('customer', fn ($c) => $c->where('name', 'like', $term)));
})
->when($this->year !== '', fn ($q) => $q->whereYear('issued_on', (int) $this->year))
->orderByDesc('issued_on')
->orderByDesc('id')
->paginate(25);
return view('livewire.admin.invoices', [
'invoices' => $invoices,
// Straight from the rows rather than a fixed range: a list of years
// with nothing in them is a filter that mostly returns nothing.
//
// Derived in PHP rather than with YEAR() in raw SQL. That function
// is MySQL's; SQLite has no such thing, and a query that only runs
// on one engine turns the test database into a different product
// from the real one.
'years' => Invoice::query()
->orderByDesc('issued_on')
->pluck('issued_on')
// ->local() even for a year (R19): on New Year's night the UTC
// year is still the previous one, so the filter would offer a
// year the operator is no longer in.
->map(fn ($date) => $date?->local()->format('Y'))
->filter()
->unique()
->values(),
]);
}
}

View File

@ -1,201 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\ConfirmsPassword;
use App\Models\Mailbox;
use App\Services\Mail\MailboxTester;
use App\Services\Mail\MailPurpose;
use App\Services\Secrets\SecretCipher;
use App\Support\Settings;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The sending addresses, and which kind of mail leaves from which.
*
* The server sits at the top because there is one of it; the mailboxes are a
* list because there are several; the mapping is last because it only makes
* sense once both exist. The test-send button lives with the mailboxes: it
* proves one specific mailbox can actually send, which is the only thing
* that makes the rest of this page more than a form.
*/
#[Layout('layouts.admin')]
class Mail extends Component
{
use ConfirmsPassword;
public string $host = '';
public int|string $port = 587;
public string $encryption = 'tls';
/** @var array<string, string> purpose => mailbox key */
public array $purposes = [];
public string $testRecipient = '';
/** @var array{ok: bool, error: ?string}|null */
public ?array $testResult = null;
public ?string $testedKey = null;
/**
* Whether credentials can be stored at all on this installation.
*
* Public so the page can say it rather than throwing on the first password
* a mailbox tries to decrypt the same courtesy the secrets page already
* extends. A missing SECRETS_KEY is a setup state, not an error.
*/
public bool $usable = true;
protected function confirmationGuard(): string
{
return 'operator';
}
public function mount(): void
{
$this->authorize('mail.manage');
$this->host = (string) Settings::get('mail.host', '');
$this->port = (int) Settings::get('mail.port', 587);
$this->encryption = (string) Settings::get('mail.encryption', 'tls');
foreach (MailPurpose::ALL as $purpose) {
$this->purposes[$purpose] = (string) Settings::get(MailPurpose::settingKey($purpose), '');
}
}
public function saveServer(): void
{
$this->authorize('mail.manage');
// $this->host is the platform's outbound relay for every purpose
// mailbox at once — pointing it at an attacker's server intercepts
// everything CluPilot sends. The capability decides who may open this
// page; a recent password decides whether THIS session may repoint
// it, the same second gate Admin\Integrations' vault entries use and
// for the same reason: the realistic threat is an unlocked machine, not a
// stranger. savePurposes() and test() stay on the capability alone —
// this is the "changing an address" split's one exception.
abort_unless($this->passwordRecentlyConfirmed(), 403);
// Rules on the ACTION, not on the property: a #[Validate] attribute on
// a Livewire property applies class-wide, and savePurposes() below
// would drag these along.
$this->validate([
'host' => ['required', 'string', 'max:255'],
'port' => ['required', 'integer', 'min:1', 'max:65535'],
'encryption' => ['required', 'in:tls,ssl,none'],
]);
// Codex R15#6, P2: last_verified_at on EVERY mailbox proves a test
// against THIS server config specifically — the shared-server mirror
// of EditMailbox::save()'s guard on a single mailbox's own address/
// username/authenticates. Compared against the STORED values, not
// just "did the operator touch the field": re-opening the card and
// saving without editing anything must not wipe a legitimate
// verification. Read before Settings::set() overwrites them below,
// and deliberately not merged with EditMailbox's own comparison — that
// one diffs a single loaded model's in-memory attributes, this one
// diffs persisted Settings against incoming scalars for a config that
// has no single row to load at all; forcing one comparison function
// over both shapes would add an abstraction with nothing genuinely
// shared to justify it. What IS shared is the actual clear itself:
// both this method and EditMailbox::save() delegate to a Mailbox::
// invalidate*() method rather than touching the column directly.
$serverChanged = $this->host !== (string) Settings::get('mail.host', '')
|| (int) $this->port !== (int) Settings::get('mail.port', 587)
|| $this->encryption !== (string) Settings::get('mail.encryption', 'tls');
Settings::set('mail.host', $this->host);
Settings::set('mail.port', (int) $this->port);
Settings::set('mail.encryption', $this->encryption);
if ($serverChanged) {
Mailbox::invalidateAllVerifications();
}
$this->dispatch('notify', message: __('mail_settings.server_saved'));
}
public function savePurposes(): void
{
$this->authorize('mail.manage');
// Reachable by posting straight to /livewire/update, past whatever
// the <select> in the view would ever submit — so "is a nonempty
// string" is not enough. Every purpose's key must name a mailbox
// that actually exists; system's additionally must be ACTIVE, because
// MailboxResolver::for() falls every unmapped (or inactive) OTHER
// purpose through to system, and there is nothing left to fall back
// to when system itself is the broken one.
$activeKeys = Mailbox::query()->where('active', true)->pluck('key')->all();
$allKeys = Mailbox::query()->pluck('key')->all();
$rules = [
// system is the fallback, so it is the one that may not be empty
// — there is nothing left to fall back to.
'purposes.system' => ['required', 'string', function ($attribute, $value, $fail) use ($activeKeys) {
if ($value !== '' && ! in_array($value, $activeKeys, true)) {
$fail(__('mail_settings.system_inactive'));
}
}],
];
foreach (MailPurpose::ALL as $purpose) {
if ($purpose === MailPurpose::SYSTEM) {
continue;
}
// Blank is a legitimate choice for every OTHER purpose —
// MailboxResolver already falls an unmapped (or inactive)
// purpose through to system on its own, so only a key that names
// NO mailbox at all (a deleted row, or a tampered payload) is
// refused here.
$rules["purposes.{$purpose}"] = ['nullable', 'string', function ($attribute, $value, $fail) use ($allKeys) {
if ($value !== '' && ! in_array($value, $allKeys, true)) {
$fail(__('mail_settings.purpose_unknown_mailbox'));
}
}];
}
$this->validate($rules, [
'purposes.system.required' => __('mail_settings.system_required'),
]);
foreach (MailPurpose::ALL as $purpose) {
Settings::set(MailPurpose::settingKey($purpose), $this->purposes[$purpose] ?? '');
}
$this->dispatch('notify', message: __('mail_settings.purposes_saved'));
}
public function test(string $uuid): void
{
$this->authorize('mail.manage');
$this->validate(
['testRecipient' => ['required', 'email']],
['testRecipient.required' => __('mail_settings.test_recipient_required')],
);
$box = Mailbox::query()->where('uuid', $uuid)->firstOrFail();
$this->testedKey = $box->key;
$this->testResult = app(MailboxTester::class)->run($box, $this->testRecipient);
}
public function render()
{
$this->usable = app(SecretCipher::class)->isUsable();
return view('livewire.admin.mail', [
'mailboxes' => Mailbox::query()->orderBy('key')->get(),
'purposeList' => MailPurpose::ALL,
'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
]);
}
}

View File

@ -1,243 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Support\LocalTime;
use App\Models\Datacenter;
use App\Models\Host;
use App\Models\MaintenanceWindow;
use App\Services\Maintenance\MaintenanceNotifier;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.admin')]
class Maintenance extends Component
{
#[Validate('required|string|max:255')]
public string $title = '';
#[Validate('nullable|string|max:2000')]
public string $publicDescription = '';
#[Validate('nullable|string|max:2000')]
public string $internalNotes = '';
#[Validate('required|date')]
public string $startsAt = '';
#[Validate('required|date')]
public string $endsAt = '';
/** @var array<int> */
public array $hostIds = [];
/** Toggle every host of one datacenter (select-all / deselect-all). */
public function selectDatacenter(string $code): void
{
$this->authorize('maintenance.manage');
$ids = Host::query()->where('datacenter', $code)->pluck('id')->map(fn ($id) => (string) $id)->all();
$current = array_map('strval', $this->hostIds);
$this->hostIds = empty(array_diff($ids, $current))
? array_values(array_diff($current, $ids)) // all selected → clear them
: array_values(array_unique([...$current, ...$ids]));
}
/**
* Set the end from the start. Typing a full timestamp by hand is the
* fiddliest part of this form, and the end is almost always "start plus a
* round number of minutes".
*/
public function setDuration(int $minutes): void
{
$this->authorize('maintenance.manage');
$start = $this->parsed($this->startsAt);
if ($start === null) {
// No start yet: assume the next half hour, which is what someone
// scheduling a window in a hurry means anyway.
$start = now()->addMinutes(30 - (now()->minute % 30))->startOfMinute();
$this->startsAt = LocalTime::toField($start);
}
$this->endsAt = LocalTime::toField($start->copy()->addMinutes($minutes));
}
/** Minutes between start and end, or null while either is unusable. */
public function durationMinutes(): ?int
{
$start = $this->parsed($this->startsAt);
$end = $this->parsed($this->endsAt);
if ($start === null || $end === null || $end->lessThanOrEqualTo($start)) {
return null;
}
return (int) $start->diffInMinutes($end);
}
/** What the operator typed, as UTC. Half-typed input is normal here. */
private function parsed(string $value): ?\Illuminate\Support\Carbon
{
return LocalTime::fromField($value);
}
public function saveDraft(): void
{
$this->authorize('maintenance.manage');
$this->persist('draft');
}
public function publish(): void
{
$this->authorize('maintenance.manage');
$window = $this->persist('scheduled');
if ($window !== null) {
app(MaintenanceNotifier::class)->announce($window);
}
}
private function persist(string $state): ?MaintenanceWindow
{
$data = $this->validate([
'title' => 'required|string|max:255',
'publicDescription' => 'nullable|string|max:2000',
'internalNotes' => 'nullable|string|max:2000',
'startsAt' => 'required|date',
'endsAt' => 'required|date',
'hostIds' => 'array',
'hostIds.*' => 'integer|exists:hosts,id',
]);
$starts = LocalTime::fromField($data['startsAt']);
$ends = LocalTime::fromField($data['endsAt']);
if ($ends->lessThanOrEqualTo($starts)) {
$this->addError('endsAt', __('maintenance.end_after_start'));
return null;
}
// A window always needs a host — otherwise a hostless draft can never be
// published (there is no edit-hosts action) and is stuck.
if (empty($this->hostIds)) {
$this->addError('hostIds', __('maintenance.need_host'));
return null;
}
if ($state === 'scheduled' && $ends->isPast()) {
$this->addError('endsAt', __('maintenance.end_future'));
return null;
}
// Create the window and attach hosts atomically so a bad id can't leave
// an orphaned scheduled window behind.
$window = DB::transaction(function () use ($data, $starts, $ends, $state) {
$window = MaintenanceWindow::create([
'title' => $data['title'],
'public_description' => $data['publicDescription'] ?: null,
'internal_notes' => $data['internalNotes'] ?: null,
'starts_at' => $starts,
'ends_at' => $ends,
'state' => $state,
'created_by' => Auth::guard('operator')->id(),
'published_at' => $state === 'scheduled' ? now() : null,
]);
$window->hosts()->sync(array_map('intval', $data['hostIds'] ?? []));
return $window;
});
$this->reset('title', 'publicDescription', 'internalNotes', 'startsAt', 'endsAt', 'hostIds');
$this->dispatch('notify', message: __($state === 'scheduled' ? 'maintenance.published' : 'maintenance.draft_saved'));
return $window;
}
public function publishExisting(string $uuid): void
{
$this->authorize('maintenance.manage');
$window = MaintenanceWindow::query()->where('uuid', $uuid)->first();
if ($window === null || $window->state !== 'draft') {
return;
}
if ($window->hosts()->count() === 0 || $window->ends_at->isPast()) {
$this->dispatch('notify', message: __('maintenance.need_host'));
return;
}
$window->update(['state' => 'scheduled', 'published_at' => now()]);
app(MaintenanceNotifier::class)->announce($window);
$this->dispatch('notify', message: __('maintenance.published'));
}
/**
* Re-run announcements for a published window. Idempotent (ledger-guarded),
* so it only fills gaps the retry path when a transient queue outage left
* some affected customers un-notified.
*/
public function resend(string $uuid): void
{
$this->authorize('maintenance.manage');
$window = MaintenanceWindow::query()->where('uuid', $uuid)->first();
// Only announce for a window that has not yet ended (derived state).
if ($window === null || ! in_array($window->derivedState(), ['upcoming', 'active'], true)) {
return;
}
app(MaintenanceNotifier::class)->announce($window);
$this->dispatch('notify', message: __('maintenance.notified'));
}
public function cancel(string $uuid): void
{
$this->authorize('maintenance.manage');
$window = MaintenanceWindow::query()->where('uuid', $uuid)->first();
// Cannot cancel what is already cancelled or has already completed.
if ($window === null || in_array($window->derivedState(), ['cancelled', 'completed'], true)) {
return;
}
$wasPublished = $window->state === 'scheduled';
$window->update(['state' => 'cancelled', 'cancelled_at' => now()]);
// Customers who received an announcement are told it's cancelled. The
// MessageSending listener suppresses still-pending announcements, and the
// MessageSent listener catches the race (an announcement that delivers
// just as we cancel) by queuing a catch-up cancellation for it.
if ($wasPublished) {
app(MaintenanceNotifier::class)->notifyCancellation($window);
}
$this->dispatch('notify', message: __('maintenance.cancelled'));
}
public function render()
{
$windows = MaintenanceWindow::query()
->withCount('hosts')
->orderByDesc('starts_at')
->get()
->map(fn (MaintenanceWindow $w) => [
'uuid' => $w->uuid,
'title' => $w->title,
'starts_at' => $w->starts_at,
'ends_at' => $w->ends_at,
'state' => $w->derivedState(),
'hosts' => $w->hosts_count,
'affected' => $w->affectedCustomers()->count(),
'is_draft' => $w->state === 'draft',
'notifiable' => $w->state === 'scheduled' && in_array($w->derivedState(), ['upcoming', 'active'], true),
'cancellable' => in_array($w->derivedState(), ['draft', 'upcoming', 'active'], true),
]);
return view('livewire.admin.maintenance', [
'windows' => $windows,
'datacenters' => Datacenter::query()->orderBy('name')->get(),
'hosts' => Host::query()->orderBy('datacenter')->orderBy('name')->get(),
'durationMinutes' => $this->durationMinutes(),
]);
}
}

View File

@ -1,304 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\BuildsRunSteps;
use App\Models\Customer;
use App\Models\Host;
use App\Models\Instance;
use App\Models\MonitoringTarget;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The console's front page every figure on it comes from the database.
*
* It used to be invented: forty-two customers, €7,842 of monthly revenue, hosts
* named pve-fsn-1..3, a twelve-month growth curve. It looked like an operating
* business and reported nothing. Anything without a real source has been
* removed rather than approximated the revenue trend line especially, because
* there is no revenue history to draw it from, and a plausible line is worse
* than no line.
*/
#[Layout('layouts.admin')]
class Overview extends Component
{
// current_step is an index into the pipeline, not a name. The provisioning
// page already owns the translation from one to the other; sharing it keeps
// the two pages from naming the same step differently.
use BuildsRunSteps;
/** A host is presumed missing once it has not checked in for this long. */
private const HOST_SILENT_AFTER_MINUTES = 30;
public function render()
{
$locale = app()->getLocale();
$customersOpen = Customer::query()->where('status', '!=', 'closed')->whereNull('closed_at')->count();
$customersSuspended = Customer::query()->where('status', 'suspended')->count();
$instancesByStatus = Instance::query()
->selectRaw('status, count(*) as n')
->groupBy('status')
->pluck('n', 'status');
$hostsByStatus = Host::query()
->selectRaw('status, count(*) as n')
->groupBy('status')
->pluck('n', 'status');
return view('livewire.admin.overview', [
'kpis' => [
[
'label' => __('admin.kpi.customers'),
'value' => (string) $customersOpen,
'sub' => $customersSuspended > 0
? __('admin.kpi.customers_suspended', ['n' => $customersSuspended])
: null,
],
[
'label' => __('admin.kpi.instances'),
'value' => (string) ($instancesByStatus['active'] ?? 0),
'sub' => __('admin.kpi.instances_of', ['n' => $instancesByStatus->sum()]),
],
[
'label' => __('admin.kpi.hosts'),
'value' => (string) ($hostsByStatus['active'] ?? 0),
'sub' => __('admin.kpi.hosts_of', ['n' => $hostsByStatus->sum()]),
],
[
'label' => __('admin.kpi.mrr'),
'value' => $this->monthlyRevenue($locale),
'sub' => __('admin.kpi.mrr_sub'),
],
],
'newInstances' => $this->newInstancesPerMonth($locale),
'hostLoad' => $this->hostLoad(),
'runs' => $this->openRuns(),
'notices' => $notices = $this->notices(),
'noticeCount' => count($notices),
]);
}
/**
* What the estate bills per month, net.
*
* Off the contracts, not the catalogue: a price rise must not inflate
* reported revenue for every grandfathered customer overnight. Add-ons
* included and yearly terms divided totalMonthlyCents() owns both rules.
*
* Grouped by currency and never summed across them. Adding francs to euros
* produces a number that is wrong in a way nobody notices.
*/
private function monthlyRevenue(string $locale): string
{
$byCurrency = [];
Subscription::query()
->where('status', 'active')
->with('addons')
->chunkById(200, function ($subscriptions) use (&$byCurrency) {
foreach ($subscriptions as $subscription) {
$currency = strtoupper((string) $subscription->currency);
$byCurrency[$currency] = ($byCurrency[$currency] ?? 0) + $subscription->totalMonthlyCents();
}
});
if ($byCurrency === []) {
return Number::currency(0, in: Subscription::catalogueCurrency(), locale: $locale);
}
return collect($byCurrency)
->map(fn (int $cents, string $currency) => Number::currency($cents / 100, in: $currency, locale: $locale))
->implode(' · ');
}
/**
* Instances created per month over the last twelve.
*
* Deliberately "created", not "in operation": reconstructing how many were
* running at the end of some past month would need a history nobody keeps,
* and the honest version of that chart is this one with a truthful label.
*
* @return array<string, mixed>
*/
private function newInstancesPerMonth(string $locale): array
{
$start = Carbon::now()->startOfMonth()->subMonths(11);
$counts = Instance::query()
->where('created_at', '>=', $start)
->get(['created_at'])
->countBy(fn (Instance $i) => $i->created_at->local()->format('Y-m'));
$labels = [];
$data = [];
for ($i = 0; $i < 12; $i++) {
$month = $start->copy()->addMonths($i);
$labels[] = $month->local()->locale($locale)->isoFormat('MMM');
$data[] = (int) ($counts[$month->local()->format('Y-m')] ?? 0);
}
return [
'empty' => array_sum($data) === 0,
'config' => [
'type' => 'bar',
'data' => [
'labels' => $labels,
'datasets' => [[
'label' => __('admin.new_instances'),
'data' => $data,
'backgroundColor' => 'token:accent',
'borderRadius' => 3,
'maxBarThickness' => 26,
]],
],
'options' => [
'scales' => [
'x' => ['grid' => ['display' => false]],
// Whole instances only — a y-axis offering "1.5 instances"
// is the giveaway that a chart was never looked at.
'y' => ['beginAtZero' => true, 'ticks' => ['precision' => 0], 'grid' => ['color' => 'token:border']],
],
'plugins' => ['legend' => ['display' => false]],
],
],
];
}
/**
* Storage committed on each host, against the capacity placement may use.
*
* Deliberately the model's own committedGb()/freeGb()/usedPct() rather than
* a quicker sum here. Placement counts the VM disk allocation, ignores a
* failed instance that never got a VM, and subtracts the host's reserve
* a dashboard doing its own arithmetic would show a host as comfortable
* while placement was already refusing to put anything on it, and the
* disagreement would only surface when an order failed.
*
* @return array<int, array<string, mixed>>
*/
private function hostLoad(): array
{
return Host::query()
// Preloaded so listing hosts is one query, not one per host.
->withSum(['instances as committed_disk_gb' => fn ($q) => $q->occupyingHost()], 'disk_gb')
->orderBy('name')
->get()
->map(function (Host $host) {
$usable = $host->freeGb();
$committed = $host->committedGb();
// Same ratio usedPct() states, computed from the two values
// already in hand rather than asking the host to sum again.
$pct = $usable > 0 ? min(100, (int) round($committed / $usable * 100)) : 0;
return [
'name' => $host->name,
'pct' => $pct,
'detail' => $usable > 0 ? "{$committed} / {$usable} GB" : __('admin.host_no_capacity'),
'level' => match (true) {
$usable === 0 => 'unknown',
$pct >= 90 => 'high',
$pct >= 75 => 'warn',
default => 'ok',
},
];
})
->all();
}
/**
* Provisioning runs that have not finished.
*
* @return array<int, array<string, mixed>>
*/
private function openRuns(): array
{
return ProvisioningRun::query()
->whereIn('status', [
ProvisioningRun::STATUS_PENDING,
ProvisioningRun::STATUS_RUNNING,
ProvisioningRun::STATUS_WAITING,
ProvisioningRun::STATUS_PAUSED,
])
// Nested through the morph, so naming the customer does not cost a
// query per run.
->with(['subject' => fn ($morph) => $morph->morphWith([Instance::class => ['customer']])])
->latest('id')
->limit(6)
->get()
->map(fn (ProvisioningRun $run) => [
'subject' => $this->describeSubject($run),
'step' => $this->currentStepLabel($run),
'status' => $run->status,
])
->all();
}
/**
* Everything currently wrong, from the records that know it.
*
* There is no invented alert here. If this list is empty, nothing in the
* database is reporting a problem which is a statement worth being able
* to trust.
*
* @return array<int, array<string, string>>
*/
private function notices(): array
{
$notices = [];
$failedRuns = ProvisioningRun::query()->where('status', ProvisioningRun::STATUS_FAILED)->count();
if ($failedRuns > 0) {
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.failed_runs', ['n' => $failedRuns])];
}
foreach (Host::query()->where('status', 'error')->pluck('name') as $name) {
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.host_error', ['host' => $name])];
}
$silent = Host::query()
->where('status', 'active')
->where(fn ($q) => $q
->whereNull('last_seen_at')
->orWhere('last_seen_at', '<', Carbon::now()->subMinutes(self::HOST_SILENT_AFTER_MINUTES)))
->pluck('name');
foreach ($silent as $name) {
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.host_silent', [
'host' => $name, 'minutes' => self::HOST_SILENT_AFTER_MINUTES,
])];
}
// Distinct instances, not targets: one instance can be watched by
// several checks, and counting checks would report three outages where
// one machine is down. And only verdicts the sync job has refreshed —
// an unchecked row is not a healthy one, nor a failing one.
$down = MonitoringTarget::query()
->where('status', 'down')
->where('checked_at', '>=', Carbon::now()->subMinutes(20))
->distinct()
->count('instance_id');
if ($down > 0) {
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.monitoring_down', ['n' => $down])];
}
return $notices;
}
private function describeSubject(ProvisioningRun $run): string
{
$subject = $run->subject;
return match (true) {
$subject instanceof Instance => $subject->customer?->name ?? $subject->subdomain ?? '—',
$subject instanceof Host => $subject->name,
default => class_basename($run->subject_type ?? '').' #'.$run->subject_id,
};
}
}

View File

@ -1,262 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Support\LocalTime;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use App\Support\Money;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use RuntimeException;
/**
* The versions of one plan: what it can do, what it costs, and when it sells.
*
* The page is built around the one rule that matters here a draft can be
* edited freely, a published version cannot be touched at all. So drafting and
* publishing are separate acts, and publishing says plainly that it is final.
*/
#[Layout('layouts.admin')]
class PlanVersions extends Component
{
public string $uuid;
/** The draft being written. */
public int $quotaGb = 100;
public int $trafficGb = 1000;
public int $seats = 5;
public int $ramMb = 4096;
public int $cores = 2;
public int $diskGb = 120;
public string $performance = 'standard';
public ?int $templateVmid = 9000;
/** @var array<int, string> */
public array $features = [];
/**
* In EUROS, as typed. The catalogue stores cents; asking the owner to type
* them too turned €799 into "79900", where one slipped digit is a factor of
* ten on an invoice. Money::toCents does the conversion, once.
*/
public string $monthlyPrice = '49,00';
public string $yearlyPrice = '588,00';
/** Publishing: when it goes on sale, and optionally when it stops. */
public string $availableFrom = '';
public string $availableUntil = '';
public ?string $publishing = null;
public function mount(string $uuid): void
{
$this->authorize('plans.manage');
$family = PlanFamily::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
// Start the draft from what this plan is today, so the common case —
// "same plan, new price" — is a single edit rather than nine.
$latest = $family->versions()->orderByDesc('version')->first();
if ($latest !== null) {
$this->quotaGb = $latest->quota_gb;
$this->trafficGb = $latest->traffic_gb;
$this->seats = $latest->seats;
$this->ramMb = $latest->ram_mb;
$this->cores = $latest->cores;
$this->diskGb = $latest->disk_gb;
$this->performance = (string) ($latest->performance ?? 'standard');
$this->templateVmid = $latest->template_vmid;
$this->features = $latest->features ?? [];
$monthly = $latest->priceFor(Subscription::TERM_MONTHLY);
$yearly = $latest->priceFor(Subscription::TERM_YEARLY);
$this->monthlyPrice = Money::fromCents($monthly?->amount_cents ?? 4900);
$this->yearlyPrice = Money::fromCents($yearly?->amount_cents ?? 58800);
}
}
private function family(): PlanFamily
{
return PlanFamily::query()->where('uuid', $this->uuid)->firstOrFail();
}
/** Write a new draft. Nothing is promised until it is published. */
public function draft(): void
{
$this->authorize('plans.manage');
// Every bound is explicit, and generous rather than tight. A mistyped
// figure has to come back as a message on the field; without an upper
// limit it overflows the column instead and the owner gets a 500 with
// no idea which number was wrong.
$data = $this->validate([
'quotaGb' => 'required|integer|min:1|max:1000000',
'trafficGb' => 'required|integer|min:0|max:10000000',
'seats' => 'required|integer|min:1|max:100000',
'ramMb' => 'required|integer|min:512|max:4194304',
'cores' => 'required|integer|min:1|max:512',
// The disk has to hold the customer's quota plus the system itself.
'diskGb' => 'required|integer|min:1|max:1000000|gte:quotaGb',
// One of the classes we actually have a label for. Free text means
// a typo is published, frozen, and shown to customers forever as a
// raw translation key.
'performance' => ['required', Rule::in(array_keys((array) __('billing.perf')))],
// Required, not nullable: a version without a blueprint can be
// published and sold, and then fails provisioning every time.
'templateVmid' => 'required|integer|min:100|max:999999999',
'features' => 'array',
// Same reason as the performance class: a request can carry
// anything, and an unknown key is frozen at publication and shown
// to customers as a raw translation key.
'features.*' => ['string', Rule::in(array_keys((array) __('billing.feature')))],
// Euros with at most two decimals, either separator. The upper
// bound lives in Money's pattern: nine whole digits is far past
// anything we would charge and far short of overflowing the column.
// Checked as a rule, not afterwards: a price fault has to be
// reported alongside every other fault on the form, and a check
// that runs after validate() never runs at all once some other
// field has already thrown.
'monthlyPrice' => ['required', 'string', $price = function (string $attribute, mixed $value, callable $fail) {
if (Money::toCents((string) $value) === null) {
$fail(__('plans.price_invalid'));
}
}],
'yearlyPrice' => ['required', 'string', $price],
]);
$monthlyCents = Money::toCents($data['monthlyPrice']);
$yearlyCents = Money::toCents($data['yearlyPrice']);
$version = app(PlanCatalogue::class)->draft(
$this->family(),
[
'quota_gb' => $data['quotaGb'],
'traffic_gb' => $data['trafficGb'],
'seats' => $data['seats'],
'ram_mb' => $data['ramMb'],
'cores' => $data['cores'],
'disk_gb' => $data['diskGb'],
'performance' => $data['performance'],
'template_vmid' => $data['templateVmid'],
'features' => array_values($data['features']),
],
// Both terms, because publishing refuses a version that is not
// priced for each — better to find that out here than in front of
// a customer.
[
Subscription::TERM_MONTHLY => $monthlyCents,
Subscription::TERM_YEARLY => $yearlyCents,
],
);
$this->dispatch('notify', message: __('plans.draft_created', ['version' => $version->version]));
}
/** Open the publish form for one draft. */
public function choose(string $versionUuid): void
{
$this->publishing = $versionUuid;
$this->availableFrom = LocalTime::toField(now());
$this->availableUntil = '';
}
public function cancelPublish(): void
{
$this->reset('publishing', 'availableFrom', 'availableUntil');
}
/**
* Publish: fix the terms and put them on sale.
*
* Everything that can refuse this an unpriced version, a window that
* overlaps another refuses before anything is written, so a rejected
* publish leaves an editable draft rather than a stranded one.
*/
public function publish(): void
{
$this->authorize('plans.manage');
$this->validate([
'availableFrom' => 'required|date',
'availableUntil' => 'nullable|date|after:availableFrom',
]);
$version = PlanVersion::query()->where('uuid', $this->publishing)->firstOrFail();
abort_unless($version->plan_family_id === $this->family()->id, 404);
try {
app(PlanCatalogue::class)->publish(
$version,
LocalTime::fromField($this->availableFrom),
LocalTime::fromField($this->availableUntil),
);
} catch (RuntimeException $e) {
$this->addError('availableFrom', $e->getMessage());
return;
}
$this->cancelPublish();
$this->dispatch('notify', message: __('plans.published', ['version' => $version->version]));
}
/**
* Close a running version's window the ordinary way to take a plan off
* sale for good, since a published version can never be deleted.
*/
public function close(string $versionUuid): void
{
$this->authorize('plans.manage');
$version = PlanVersion::query()->where('uuid', $versionUuid)->firstOrFail();
abort_unless($version->plan_family_id === $this->family()->id, 404);
try {
app(PlanCatalogue::class)->schedule($version, $version->available_from, now());
} catch (RuntimeException $e) {
$this->addError('availableFrom', $e->getMessage());
return;
}
$this->dispatch('notify', message: __('plans.closed', ['version' => $version->version]));
}
#[On('plan-draft-deleted')]
public function refresh(): void
{
// Livewire re-renders; the listener exists so the modal can say so.
}
public function render()
{
$family = $this->family();
$now = now();
return view('livewire.admin.plan-versions', [
'family' => $family,
'versions' => $family->versions()->with('prices')->orderByDesc('version')->get(),
'now' => $now,
'featureKeys' => array_keys((array) __('billing.feature')),
'performanceClasses' => (array) __('billing.perf'),
'currency' => Subscription::catalogueCurrency(),
]);
}
}

View File

@ -1,116 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\PlanFamily;
use App\Services\Billing\PlanCatalogue;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
/**
* The plan lines we sell, and whether each is currently on sale.
*
* Deliberately shallow: a family is a name and a rank, and everything that can
* be got wrong capabilities, prices, windows lives one level down on the
* versions, where publishing makes it permanent.
*/
#[Layout('layouts.admin')]
class Plans extends Component
{
#[Validate(['required', 'string', 'max:32', 'regex:/^[a-z][a-z0-9_]*$/', 'unique:plan_families,key'])]
public string $key = '';
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|integer|min:0|max:255')]
public int $tier = 1;
public function mount(): void
{
// The page itself, not only its buttons. Hiding the nav entry and
// guarding the mutations still leaves the whole catalogue — prices,
// drafts, unreleased plans — readable to any operator who types the URL.
$this->authorize('plans.manage');
}
public function save(): void
{
$this->authorize('plans.manage');
$this->key = strtolower(trim($this->key));
$data = $this->validate([
// The key is permanent once created — orders, instances and every
// contract snapshot store it as a string — so it has to be a plain
// identifier, not something anyone will want to prettify later.
'key' => ['required', 'string', 'max:32', 'regex:/^[a-z][a-z0-9_]*$/', 'unique:plan_families,key'],
'name' => 'required|string|max:255',
'tier' => 'required|integer|min:0|max:255',
]);
PlanFamily::create([
'key' => $data['key'],
'name' => $data['name'],
'tier' => $data['tier'],
// Nothing to sell until a version is published, so it starts able
// to sell and simply has nothing available.
'sales_enabled' => true,
]);
$this->reset('key', 'name', 'tier');
$this->tier = 1;
$this->dispatch('notify', message: __('plans.created'));
}
/**
* The kill switch: stop selling this plan now, without touching a window.
*
* Existing customers keep their contract and their machine this only
* decides whether anyone new can buy it.
*/
public function toggleSales(string $uuid): void
{
$this->authorize('plans.manage');
$family = PlanFamily::query()->where('uuid', $uuid)->first();
$family?->update(['sales_enabled' => ! $family->sales_enabled]);
$this->dispatch('notify', message: __($family?->sales_enabled ? 'plans.now_selling' : 'plans.withdrawn'));
}
public function render()
{
$now = now();
$families = PlanFamily::query()
->with(['versions.prices'])
->withCount('versions')
->orderBy('tier')
->get()
->map(fn (PlanFamily $family) => [
'uuid' => $family->uuid,
'key' => $family->key,
'name' => $family->name,
'tier' => $family->tier,
'sales_enabled' => $family->sales_enabled,
'versions_count' => $family->versions_count,
'live' => $family->versions->first(fn ($v) => $v->isAvailableAt($now)),
// What is queued up next, so a scheduled launch is visible
// before a customer discovers it.
'next' => $family->versions
->filter(fn ($v) => $v->isPublished() && $v->available_from->greaterThan($now))
->sortBy('available_from')
->first(),
'drafts' => $family->versions->filter(fn ($v) => ! $v->isPublished())->count(),
]);
return view('livewire.admin.plans', [
'families' => $families,
// The shop's own answer, so this page cannot disagree with what a
// customer actually sees.
'sellable' => array_keys(app(PlanCatalogue::class)->sellable()),
]);
}
}

View File

@ -1,160 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\BuildsRunSteps;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.admin')]
class Provisioning extends Component
{
use BuildsRunSteps;
#[On('echo-private:admin.runs,StepAdvanced')]
public function onStepAdvanced(): void
{
// A round-trip re-renders with fresh run state.
}
/** Retry a failed run from its current step (mirrors the host-detail retry). */
public function retry(string $uuid): void
{
$this->authorize('provisioning.retry');
// Atomically claim the run: only the request that transitions it out of
// FAILED proceeds, so two concurrent retries can't double-dispatch.
$claimed = ProvisioningRun::query()
->where('uuid', $uuid)
->where('status', ProvisioningRun::STATUS_FAILED)
->update([
'status' => ProvisioningRun::STATUS_RUNNING,
'attempt' => 0,
'next_attempt_at' => now(),
'started_at' => now(), // reset the step timer so it doesn't re-time-out instantly
'error' => null,
]);
if ($claimed === 0) {
return; // already retried by a concurrent request
}
$run = ProvisioningRun::query()->where('uuid', $uuid)->first();
if ($run === null) {
return;
}
// Move the subject out of its error state so the console reflects the retry.
$subject = $run->subject;
if ($subject instanceof Host) {
$subject->update(['status' => 'onboarding']);
} elseif ($subject instanceof Order) {
$subject->update(['status' => 'provisioning']);
Instance::query()->where('order_id', $subject->id)->where('status', 'failed')->update(['status' => 'provisioning']);
}
AdvanceRunJob::dispatch($run->uuid);
$this->dispatch('notify', message: __('admin.run_retried'));
}
/**
* A run that claims to be in progress but hasn't advanced in a while looks
* stuck unless it is in a legitimate scheduled backoff (next_attempt_at in
* the future, e.g. RunRunner's retry delay of up to 300 s).
*/
private function isStale(ProvisioningRun $run): bool
{
if (! in_array($run->status, ['running', 'waiting'], true)) {
return false;
}
if ($run->next_attempt_at !== null && $run->next_attempt_at->isFuture()) {
return false; // waiting out a scheduled retry/poll — not stuck
}
// Compare against the CURRENT step's own allowed duration — some steps
// (VM clone, Proxmox install) legitimately run for many minutes — plus a
// small grace. Only past its own deadline without advancing is it stuck.
$pipeline = config('provisioning.pipelines.'.$run->pipeline, []);
$class = $pipeline[$run->current_step] ?? null;
$maxSeconds = $class !== null ? app($class)->maxDuration() : 120;
return $run->updated_at !== null && $run->updated_at->lt(now()->subSeconds($maxSeconds + 30));
}
private function subjectLabel(ProvisioningRun $run): string
{
$subject = $run->subject;
if ($subject instanceof Order) {
return $subject->customer?->name ?? 'Order';
}
if ($subject instanceof Host) {
return $subject->name;
}
return class_basename($run->subject_type);
}
public function render()
{
$runs = ProvisioningRun::query()->latest('id')->limit(30)->get();
$active = $runs->first(fn (ProvisioningRun $r) => in_array(
$r->status, ['pending', 'running', 'waiting'], true
)) ?? $runs->first();
return view('livewire.admin.provisioning', [
'hasActive' => $active !== null && in_array($active->status, ['pending', 'running', 'waiting'], true),
'rows' => $runs->map(function (ProvisioningRun $r) {
$total = max(count(config('provisioning.pipelines.'.$r->pipeline, [])), 1);
$done = $r->status === ProvisioningRun::STATUS_COMPLETED ? $total : $r->current_step;
return [
'uuid' => $r->uuid,
'customer' => $this->subjectLabel($r),
'pipeline' => $r->pipeline,
'step' => $r->status === 'completed' ? '—' : $this->currentStepLabel($r),
'n' => ($r->current_step + 1).'/'.$total,
'percent' => (int) round($done / $total * 100),
'attempt' => $r->attempt,
'state' => $this->runState($r),
'failed' => $r->status === ProvisioningRun::STATUS_FAILED,
'activity' => $r->updated_at?->diffForHumans() ?? '—',
'stale' => $this->isStale($r),
];
})->all(),
'panel' => $active ? $this->panelFor($active) : null,
]);
}
/** Compact "current run" readout for the right column. */
private function panelFor(ProvisioningRun $run): array
{
$pipeline = config('provisioning.pipelines.'.$run->pipeline, []);
$total = max(count($pipeline), 1);
$current = min($run->current_step, $total - 1);
$completed = $run->status === ProvisioningRun::STATUS_COMPLETED;
$done = $completed ? $total : $current;
return [
'uuid' => $run->uuid,
'subject' => $this->subjectLabel($run),
'pipeline' => $run->pipeline,
'attempt' => $run->attempt,
'status' => $this->runState($run), // running|done|failed
'failed' => $run->status === ProvisioningRun::STATUS_FAILED,
'current' => $completed ? null : (isset($pipeline[$current]) ? __(app($pipeline[$current])->label()) : null),
'next' => isset($pipeline[$current + 1]) && ! $completed ? __(app($pipeline[$current + 1])->label()) : null,
'error' => $run->error,
'done' => $done,
'total' => $total,
'percent' => (int) round($done / $total * 100),
'activity' => $run->updated_at?->diffForHumans() ?? '—',
'started' => $run->started_at?->diffForHumans() ?? null,
'stale' => $this->isStale($run),
];
}
}

View File

@ -1,203 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Revenue, from the contracts and the commercial register.
*
* What was here before was a story: €7,842 MRR, 1.8 % churn, a twelve-month
* curve, four payments from customers who do not exist. All of it is gone.
*
* Two figures that used to be shown are not shown any more, because there is
* nothing to compute them from and an estimate would be indistinguishable from
* a measurement:
*
* - the MRR trend, which needs a monthly revenue history nobody records;
* - churn, which needs cancellations over a period, and cancelled_at alone
* does not say what the base was.
*
* ARR is shown, but only as what it is: this month's recurring revenue times
* twelve, labelled as a projection rather than as measured turnover.
*/
#[Layout('layouts.admin')]
class Revenue extends Component
{
public function render()
{
$locale = app()->getLocale();
$totals = $this->recurringTotals();
return view('livewire.admin.revenue', [
'kpis' => [
[
'label' => __('admin.rev.mrr'),
'value' => $this->format($totals, fn (array $t) => $t['cents'] / 100, $locale),
'sub' => __('admin.rev.mrr_sub'),
],
[
'label' => __('admin.rev.arr'),
'value' => $this->format($totals, fn (array $t) => $t['cents'] * 12 / 100, $locale),
'sub' => __('admin.rev.arr_sub'),
],
[
'label' => __('admin.rev.arpu'),
'value' => $this->format(
$totals,
fn (array $t) => count($t['customers']) > 0 ? $t['cents'] / count($t['customers']) / 100 : 0,
$locale,
),
'sub' => __('admin.rev.arpu_sub'),
],
[
'label' => __('admin.rev.contracts'),
'value' => (string) array_sum(array_column($totals, 'contracts')),
'sub' => __('admin.rev.contracts_sub'),
],
],
'planCharts' => $this->planCharts(),
'payments' => $this->recentPayments($locale),
]);
}
/**
* Recurring revenue per currency, off the contracts.
*
* Never summed across currencies. Frozen contract prices, not the
* catalogue, so a price rise does not retroactively inflate what
* grandfathered customers are reported to pay. Add-ons included and yearly
* terms divided totalMonthlyCents() owns both of those rules.
*
* @return array<string, array{cents:int, customers:array<int,bool>, contracts:int}>
*/
private function recurringTotals(): array
{
$totals = [];
Subscription::query()
->where('status', 'active')
->with('addons')
->chunkById(200, function ($subscriptions) use (&$totals) {
foreach ($subscriptions as $subscription) {
$currency = strtoupper((string) $subscription->currency);
$totals[$currency] ??= ['cents' => 0, 'customers' => [], 'contracts' => 0];
$totals[$currency]['cents'] += $subscription->totalMonthlyCents();
$totals[$currency]['contracts']++;
// A customer with two contracts is one customer — ARPU
// divided by contracts would understate it.
$totals[$currency]['customers'][(int) $subscription->customer_id] = true;
}
});
return $totals;
}
/**
* @param array<string, array{cents:int, customers:array<int,bool>, contracts:int}> $totals
* @param callable(array{cents:int, customers:array<int,bool>, contracts:int}): float $amount
*/
private function format(array $totals, callable $amount, string $locale): string
{
if ($totals === []) {
return Number::currency(0, in: Subscription::catalogueCurrency(), locale: $locale);
}
return collect($totals)
->map(fn (array $t, string $currency) => Number::currency($amount($t), in: $currency, locale: $locale))
->implode(' · ');
}
/**
* Recurring revenue split by plan one chart per currency.
*
* By what customers are ON, not by what is on sale: a withdrawn plan still
* bills, and leaving it out would understate the total the chart sits next
* to.
*
* Per currency, because a doughnut adds its slices together. Two currencies
* in one ring produces a total that is not an amount of anything, and it
* looks exactly as convincing as a correct one.
*
* @return array<int, array{currency:string, config:array<string,mixed>}>
*/
private function planCharts(): array
{
$byCurrency = [];
Subscription::query()
->where('status', 'active')
->with('addons')
->chunkById(200, function ($subscriptions) use (&$byCurrency) {
foreach ($subscriptions as $subscription) {
$currency = strtoupper((string) $subscription->currency);
$plan = (string) $subscription->plan;
$byCurrency[$currency][$plan] = ($byCurrency[$currency][$plan] ?? 0)
+ $subscription->totalMonthlyCents();
}
});
$charts = [];
foreach ($byCurrency as $currency => $byPlan) {
arsort($byPlan);
$charts[] = [
'currency' => $currency,
'config' => [
'type' => 'doughnut',
'data' => [
'labels' => array_map(fn (string $key) => __('billing.plan.'.$key), array_keys($byPlan)),
'datasets' => [[
'data' => array_map(fn (int $cents) => round($cents / 100, 2), array_values($byPlan)),
'backgroundColor' => ['token:accent', 'token:info', 'token:success-bright', 'token:warning'],
'borderWidth' => 0,
]],
],
'options' => [
'cutout' => '62%',
'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]],
],
],
];
}
return $charts;
}
/**
* The last payments actually recorded.
*
* Both kinds count: an ordinary billing cycle is written as `renewal`, and
* `invoice_paid` is reserved for everything else Stripe charges for a
* proration, a manual invoice. Listing only the second would show a
* payments panel with no ordinary payments in it.
*
* Gross, because that is what left the customer's account. The register
* holds net and tax separately for the books.
*
* @return array<int, array<string, string>>
*/
private function recentPayments(string $locale): array
{
return SubscriptionRecord::query()
->whereIn('event', [SubscriptionRecord::EVENT_RENEWAL, SubscriptionRecord::EVENT_INVOICE_PAID])
->orderByDesc('occurred_at')
->limit(8)
->get()
->map(fn (SubscriptionRecord $r) => [
'customer' => $r->customer_name ?: '—',
'amount' => Number::currency(
(int) ($r->gross_cents ?: $r->net_cents) / 100,
in: strtoupper((string) ($r->currency ?: Subscription::catalogueCurrency())),
locale: $locale,
),
'when' => $r->occurred_at?->local()->translatedFormat('d. MMM') ?? '—',
])
->all();
}
}

View File

@ -1,62 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\ResolvesOperator;
use App\Services\Devices\SessionRegistry;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* "Where am I signed in", for an operator.
*
* Deliberately not the portal's component with a guard passed in: two
* identities, two guards, two tables (R21), and a component that took the guard
* as input would be one forged property away from ending a customer's sessions
* from the console. The view is shared, because markup is not identity.
*/
class Sessions extends Component
{
use ResolvesOperator;
private const GUARD = 'operator';
public function end(string $uuid): void
{
if (! $operator = $this->currentOperator()) {
return;
}
app(SessionRegistry::class)->end(self::GUARD, (int) $operator->getKey(), $uuid);
$this->dispatch('notify', message: __('sessions.ended'));
}
/** Raised by the confirmation modal (R23), never called from the markup. */
#[On('sessions-end-others-confirmed')]
public function endOthers(): void
{
if (! $operator = $this->currentOperator()) {
return;
}
$registry = app(SessionRegistry::class);
$count = $registry->endOthers(self::GUARD, (int) $operator->getKey(), $registry->currentUuid());
$this->dispatch('notify', message: __('sessions.ended_others', ['n' => $count]));
}
public function render()
{
$operator = $this->currentOperator();
$registry = app(SessionRegistry::class);
$current = $registry->currentUuid();
return view('livewire.sessions', [
'sessions' => $operator
? $registry->for(self::GUARD, (int) $operator->getKey(), $current)
: collect(),
'confirmComponent' => 'admin.confirm-end-other-sessions',
]);
}
}

View File

@ -1,484 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Http\Middleware\RestrictConsoleNetwork;
use App\Livewire\Concerns\ChangesOwnPassword;
use App\Livewire\Concerns\ResolvesOperator;
use App\Models\Customer;
use App\Models\Operator;
use App\Models\VpnPeer;
use App\Provisioning\Jobs\ApplyVpnPeer;
use App\Services\Deployment\UpdateChannel;
use App\Support\Settings as AppSettings;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Permission\Models\Role;
use Symfony\Component\HttpFoundation\IpUtils;
/**
* Operator settings: the signed-in operator's own account plus (Owner-only)
* staff management. All staff mutations are capability-gated server-side and
* protect the last-Owner and self-role invariants transactionally.
*
* Two-factor ENROLMENT lives on its own page, Admin\TwoFactorSetup see
* RequireOperatorTwoFactor for why. Only the POLICY switch
* (saveTwoFactorPolicy(), below) stays here: only someone who already
* satisfies the policy should be the one changing it.
*/
#[Layout('layouts.admin')]
class Settings extends Component
{
use ChangesOwnPassword;
use ResolvesOperator;
/** This page changes the signed-in OPERATOR's password, never a portal one. */
protected function passwordAccountGuard(): string
{
return 'operator';
}
// My account
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|email|max:255')]
public string $email = '';
// Invite staff
#[Validate('required|string|max:255')]
public string $staffName = '';
#[Validate('required|email|max:255')]
public string $staffEmail = '';
#[Validate('required|in:Owner,Admin,Support,Billing,Read-only')]
public string $staffRole = 'Support';
/** Shown once after inviting, since email delivery is still mocked. */
public ?string $invitedEmail = null;
public ?string $invitedPassword = null;
public function mount(): void
{
if (! $operator = $this->currentOperator()) {
return;
}
$this->name = $operator->name;
$this->email = $operator->email;
$this->requireTwoFactor = AppSettings::bool('console.require_2fa', false);
}
/**
* Take the marketing site and the customer portal offline, or back online.
* The console keeps working either way otherwise this switch could only
* ever be flipped once.
*/
public function toggleSiteVisibility(): void
{
$this->authorize('site.manage');
$public = ! AppSettings::bool('site.public', true);
AppSettings::set('site.public', $public);
$this->dispatch('notify', message: $public
? __('admin_settings.site_now_public')
: __('admin_settings.site_now_hidden'));
}
public function saveAccount(): void
{
if (! $operator = $this->currentOperator()) {
return;
}
$data = $this->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|max:255|unique:operators,email,'.$operator->id,
]);
// An operator email must never collide with a customer's — that would
// block the customer from ever obtaining a portal login (ensureUser).
// Checked directly against `users`, not only `customers`: see
// Customer::emailTaken().
if (Customer::emailTaken($data['email'])) {
$this->addError('email', __('admin_settings.is_customer'));
return;
}
$operator->update($data);
$this->dispatch('notify', message: __('admin_settings.account_saved'));
}
public function inviteStaff(): void
{
$this->authorize('staff.manage');
if (! $operator = $this->currentOperator()) {
return;
}
$data = $this->validate([
'staffName' => 'required|string|max:255',
'staffEmail' => 'required|email|max:255|unique:operators,email',
'staffRole' => 'required|in:Owner,Admin,Support,Billing,Read-only',
]);
// Only an Owner may create another Owner.
if ($data['staffRole'] === 'Owner' && ! $operator->hasRole('Owner')) {
$this->addError('staffRole', __('admin_settings.owner_only'));
return;
}
// Never turn a customer's portal login into an operator.
if (Customer::emailTaken($data['staffEmail'])) {
$this->addError('staffEmail', __('admin_settings.is_customer'));
return;
}
// Email/password-setup delivery is still mocked, so generate a temporary
// password and surface it once to the Owner to share securely — the
// account is usable immediately (a proper invite link follows with mail).
$temp = Str::password(14);
$newStaff = Operator::create([
'name' => $data['staffName'],
'email' => $data['staffEmail'],
'password' => Hash::make($temp),
]);
$newStaff->assignRole($data['staffRole']);
$this->invitedEmail = $data['staffEmail'];
$this->invitedPassword = $temp;
$this->reset('staffName', 'staffEmail');
$this->staffRole = 'Support';
$this->dispatch('notify', message: __('admin_settings.staff_invited'));
}
public function setStaffRole(int $id, string $role): void
{
$this->authorize('staff.manage');
if (! in_array($role, Operator::OPERATOR_ROLES, true)) {
return;
}
if (! $operator = $this->currentOperator()) {
return;
}
$result = DB::transaction(function () use ($id, $role, $operator) {
// Serialize on the Owner role so the global owner count can't be
// raced to zero by concurrent demotions of different owners.
Role::query()->where('name', 'Owner')->lockForUpdate()->first();
$target = Operator::query()->whereKey($id)->lockForUpdate()->first();
if ($target === null) {
return 'gone';
}
if ($target->id === $operator->id) {
return 'self';
}
// Only existing operators may be re-roled. The Customer check below
// can no longer fire in practice — operators and customers are
// different tables now, so a tampered id cannot resolve to one from
// the other — but it costs nothing to leave as a second line.
if (! $target->isOperator() || Customer::query()->where('email', $target->email)->exists()) {
return 'not_staff';
}
// Granting or revoking Owner is Owner-only.
if (($role === 'Owner' || $target->hasRole('Owner')) && ! $operator->hasRole('Owner')) {
return 'owner_only';
}
// Never demote the last Owner.
if ($target->hasRole('Owner') && $role !== 'Owner' && $this->ownerCount() <= 1) {
return 'last_owner';
}
$target->syncRoles([$role]);
return 'ok';
});
$this->flash($result);
}
public function revokeStaff(int $id): void
{
$this->authorize('staff.manage');
$revokedPeers = [];
$result = DB::transaction(function () use ($id, &$revokedPeers) {
Role::query()->where('name', 'Owner')->lockForUpdate()->first();
$target = Operator::query()->whereKey($id)->lockForUpdate()->first();
if ($target === null || ! $target->isOperator()) {
return 'gone';
}
if ($target->id === Auth::guard('operator')->id()) {
return 'self';
}
if ($target->hasRole('Owner') && $this->ownerCount() <= 1) {
return 'last_owner';
}
$target->syncRoles([]);
// Taking away the console but leaving the tunnel would be the worst
// of both: no access to the panel, still a route into the
// management network. Same revocation path as the VPN page uses.
foreach (VpnPeer::query()->where('user_id', $target->id)->get() as $peer) {
$peer->purgeSecret();
$peer->delete();
$revokedPeers[] = $peer->public_key;
}
return 'revoked';
});
// Dispatched only once the rows are committed: a worker picking the job
// up mid-transaction would still see the peer as active, leave it on the
// hub, and never be asked again — a revoked colleague would keep their
// tunnel until the next reconciliation happened to notice.
foreach ($revokedPeers as $publicKey) {
ApplyVpnPeer::dispatch($publicKey, null, false);
}
$this->flash($result);
}
private function flash(string $result): void
{
$msg = match ($result) {
'ok' => __('admin_settings.role_updated'),
'revoked' => __('admin_settings.staff_revoked'),
'self' => __('admin_settings.not_self'),
'owner_only' => __('admin_settings.owner_only'),
'last_owner' => __('admin_settings.last_owner'),
'not_staff' => __('admin_settings.not_staff'),
default => null,
};
if ($msg !== null) {
$this->dispatch('notify', message: $msg);
}
}
private function ownerCount(): int
{
return Operator::query()->whereHas('roles', fn ($q) => $q->where('name', 'Owner'))->count();
}
/** A new entry for the console allowlist: a single address or a CIDR range. */
public string $consoleIp = '';
/**
* Add a network that may reach the console without the VPN.
*
* Validated as an address or CIDR before it is stored: a typo that silently
* matches nothing is how someone locks themselves out while believing they
* have not.
*/
public function addConsoleIp(): void
{
$this->authorize('site.manage');
$value = trim($this->consoleIp);
if (! RestrictConsoleNetwork::isNetwork($value)) {
$this->addError('consoleIp', __('admin_settings.console_ip_invalid'));
return;
}
$list = (array) AppSettings::get('console.allowed_ips', []);
if (! in_array($value, $list, true)) {
$list[] = $value;
AppSettings::set('console.allowed_ips', array_values($list));
}
$this->consoleIp = '';
$this->dispatch('notify', message: __('admin_settings.console_ip_added', ['ip' => $value]));
}
/**
* Remove one unless it is the reason you can see this page.
*
* Refusing here rather than warning afterwards: by the time the page
* reloads, the request that would show the warning has already been
* rejected. The VPN is never in this list, so an operator on the VPN can
* always clear it out.
*/
public function removeConsoleIp(string $value): void
{
$this->authorize('site.manage');
$list = array_values(array_filter(
(array) AppSettings::get('console.allowed_ips', []),
fn ($entry) => $entry !== $value,
));
$ip = (string) request()->ip();
if (RestrictConsoleNetwork::isRestricted()
&& ! RestrictConsoleNetwork::wouldStillAllow($ip, $list)) {
$this->dispatch('notify', message: __('admin_settings.console_ip_last', ['ip' => $ip]));
return;
}
AppSettings::set('console.allowed_ips', $list);
$this->dispatch('notify', message: __('admin_settings.console_ip_removed', ['ip' => $value]));
}
/**
* Switch the restriction on or off.
*
* Turning it ON is refused unless the address doing the switching is
* already covered otherwise the click that secures the console is also
* the click that locks its owner out of it.
*/
public function toggleConsoleRestriction(): void
{
$this->authorize('site.manage');
$on = ! RestrictConsoleNetwork::isRestricted();
if ($on && ! RestrictConsoleNetwork::allows((string) request()->ip())) {
$this->dispatch('notify', message: __('admin_settings.console_lock_refused', ['ip' => (string) request()->ip()]));
return;
}
AppSettings::set('console.network_restricted', $on);
$this->dispatch('notify', message: __($on ? 'admin_settings.console_locked' : 'admin_settings.console_unlocked'));
}
/** Whether two-factor is compulsory for every operator on the console. */
public bool $requireTwoFactor = false;
/**
* Make two-factor compulsory for every operator, or make it voluntary again.
*
* The lock-out guard, same shape as console.allowed_ips: the page that
* could switch this back off sits behind the switch.
*/
public function saveTwoFactorPolicy(): void
{
$this->authorize('site.manage');
if ($this->requireTwoFactor && Auth::guard('operator')->user()?->two_factor_confirmed_at === null) {
$this->addError('requireTwoFactor', __('admin_settings.two_factor_self_first'));
return;
}
AppSettings::set('console.require_2fa', $this->requireTwoFactor);
$this->dispatch('notify', message: __('admin_settings.saved'));
}
/**
* Ask the host-side agent to update this installation a real run.
*
* Deliberately a request rather than an action: see UpdateChannel. The
* button cannot report success, because success means this container is
* restarted out from under the response so it reports that the update was
* asked for, and the page shows the outcome once the agent has written it.
*
* The sibling of requestCheck() below: this one applies, downtime and all
* but only a released version above the one installed. There is no longer
* such a thing as "update to whatever is on the branch".
*/
public function requestUpdate(): void
{
$this->authorize('site.manage');
if (! $operator = $this->currentOperator()) {
return;
}
// Checked here as well as in the markup. The button is disabled when
// there is nothing released to install, and the agent refuses such a
// request too — but a Livewire action is a public endpoint, and the one
// place a rule may not live is only in the disabled attribute of a
// button. Queueing a run for nothing costs a maintenance window.
if (! app(UpdateChannel::class)->state()['available']) {
$this->dispatch('notify', message: __('admin_settings.update_nothing_released'));
return;
}
$accepted = app(UpdateChannel::class)->request($operator->email);
$this->dispatch('notify', message: __($accepted
? 'admin_settings.update_requested'
: 'admin_settings.update_already_requested'));
}
/**
* Ask the host-side agent to look for updates never to apply one.
*
* For the operator who only wants an answer: no maintenance window, no
* restart, just the next tick's reading reported back.
*/
public function requestCheck(): void
{
$this->authorize('site.manage');
if (! $operator = $this->currentOperator()) {
return;
}
$accepted = app(UpdateChannel::class)->requestCheck($operator->email);
$this->dispatch('notify', message: __($accepted
? 'admin_settings.update_check_requested'
: 'admin_settings.update_already_requested'));
}
public function render()
{
$operator = $this->currentOperator();
$staff = Operator::query()
->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::OPERATOR_ROLES))
->with('roles')
->orderBy('name')
->get()
->map(fn (Operator $u) => [
'id' => $u->id,
'name' => $u->name,
'email' => $u->email,
'role' => $u->roles->first()?->name ?? '—',
'self' => $u->id === Auth::guard('operator')->id(),
]);
return view('livewire.admin.settings', [
'update' => app(UpdateChannel::class)->state(),
'updateLog' => app(UpdateChannel::class)->lastLog(),
'sitePublic' => AppSettings::bool('site.public', true),
'canManageSite' => $operator?->can('site.manage') ?? false,
// Shown so nobody has to guess why they still see the real site.
'viewerOnVpn' => IpUtils::checkIp(
(string) request()->ip(),
(array) config('admin_access.trusted_ranges', []),
),
// Who may reach the console, and from where the viewer is asking —
// shown so the consequence of a change is visible before it is made.
'consoleRestricted' => RestrictConsoleNetwork::isRestricted(),
'consoleIps' => (array) AppSettings::get('console.allowed_ips', []),
'consoleVpnRanges' => (array) config('admin_access.trusted_ranges', []),
'viewerIp' => (string) request()->ip(),
'staff' => $staff,
'roles' => Operator::OPERATOR_ROLES,
'canManageStaff' => $operator?->can('staff.manage') ?? false,
'isOwner' => $operator?->hasRole('Owner') ?? false,
]);
}
}

View File

@ -1,199 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\ConfirmsPassword;
use App\Livewire\Concerns\ResolvesOperator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Where an operator sets their own two-factor login up.
*
* Its own page, not a card on Admin\Settings. RequireOperatorTwoFactor must
* be able to exempt ENROLMENT alone while compulsory two-factor is on the
* earlier shape exempted the whole of Admin\Settings on the theory that it
* was "the page where two-factor is set up", but that component also carries
* staff management, site visibility, network restrictions, account changes,
* and the two-factor policy switch itself. An unenrolled operator got
* unrestricted access to all of that, not just enrolment (Codex R15, P1b).
* One route to exempt is one decision; eight actions on a shared page each
* remembering to check is eight chances to forget one.
*
* Reachable by EVERY operator, not gated behind any capability the
* compulsory switch (Admin\Settings::saveTwoFactorPolicy()) applies to every
* member of staff, so enrolment has to be reachable by every operator too.
*
* Every action is re-checked, server-side, against a recent password
* confirmation on every single call not merely by hiding the buttons: a
* Livewire action is reachable by anyone who can post to /livewire/update,
* and "the form was not on screen" has never stopped anybody.
*/
#[Layout('layouts.admin')]
class TwoFactorSetup extends Component
{
use ConfirmsPassword;
use ResolvesOperator;
/** Two-factor is confirmed against the operator's own record, not a portal one. */
protected function confirmationGuard(): string
{
return 'operator';
}
/** Shown once, right after setting two-factor up. */
public ?array $recoveryCodes = null;
public string $twoFactorCode = '';
/**
* Start two-factor setup: generate the secret, then show the QR code.
*
* Not confirmed yet Fortify keeps `two_factor_confirmed_at` null until a
* code from the app has been accepted, so someone who scans nothing and
* closes the tab is not left locked out of their own account.
*/
public function enableTwoFactor(): void
{
$this->requireConfirmedPassword();
if (! $operator = $this->currentOperator()) {
return;
}
app(EnableTwoFactorAuthentication::class)($operator);
}
/**
* Step back out of a half-finished setup.
*
* Without this the only way out of the QR step was to navigate away, which
* left an unconfirmed secret sitting on the account invisible, because
* nothing shows a pending enrolment anywhere else. Clearing it costs
* nothing: it was never confirmed, so it never protected anything.
*/
public function cancelSetup(): void
{
$this->requireConfirmedPassword();
if (! $operator = $this->currentOperator()) {
return;
}
app(DisableTwoFactorAuthentication::class)($operator);
$this->twoFactorCode = '';
$this->resetErrorBag('twoFactorCode');
$this->dispatch('notify', message: __('two_factor_setup.cancelled'));
}
/** Accept a code from the app, which is what actually turns it on. */
public function confirmTwoFactor(): void
{
$this->requireConfirmedPassword();
if (! $operator = $this->currentOperator()) {
return;
}
try {
app(ConfirmTwoFactorAuthentication::class)(
$operator,
$this->twoFactorCode,
);
} catch (ValidationException $e) {
$this->addError('twoFactorCode', $e->errors()['code'][0] ?? __('two_factor_setup.code_wrong'));
return;
}
$this->twoFactorCode = '';
// Shown once, here, because this is the only moment they exist in a
// form anyone will read. They stay retrievable afterwards, but nobody
// writes down what they were not shown.
$this->recoveryCodes = json_decode(decrypt($operator->two_factor_recovery_codes), true);
$this->dispatch('notify', message: __('two_factor_setup.on'));
}
public function regenerateRecoveryCodes(): void
{
$this->requireConfirmedPassword();
if (! $operator = $this->currentOperator()) {
return;
}
app(GenerateNewRecoveryCodes::class)($operator);
$this->recoveryCodes = json_decode(decrypt($operator->refresh()->two_factor_recovery_codes), true);
}
public function disableTwoFactor(): void
{
$this->requireConfirmedPassword();
if (! $operator = $this->currentOperator()) {
return;
}
app(DisableTwoFactorAuthentication::class)($operator);
$this->recoveryCodes = null;
$this->dispatch('notify', message: __('two_factor_setup.off'));
}
/**
* The disable button opens ConfirmDisableTwoFactor instead of calling
* disableTwoFactor() directly (R23); its confirm button dispatches back
* here, and disableTwoFactor() still runs its own password check.
*/
#[On('two-factor-disable-confirmed')]
public function onDisableConfirmed(): void
{
$this->disableTwoFactor();
}
/**
* Every two-factor action goes through this.
*
* Server-side, on each call, and not merely by hiding the buttons: a
* Livewire action is reachable by anyone who can post to /livewire/update,
* and "the form was not on screen" has never stopped anybody.
*/
private function requireConfirmedPassword(): void
{
abort_unless($this->passwordRecentlyConfirmed(), 403);
}
public function render()
{
// Unlike the actions above, render() has no requireConfirmedPassword()
// gate in front of it — Livewire calls it on every update, including a
// bare property sync, so it is its own reachable path for a session
// that ended after the page loaded.
if (! $operator = $this->currentOperator()) {
return view('livewire.admin.two-factor-setup', [
'twoFactorPending' => false,
'twoFactorOn' => false,
'twoFactorQr' => null,
'passwordConfirmed' => false,
]);
}
return view('livewire.admin.two-factor-setup', [
// Never the secret itself — only whether it exists, and the SVG
// Fortify renders from it. The secret in a Livewire property would
// travel to the browser and back in the component snapshot.
'twoFactorPending' => $operator->two_factor_secret !== null && $operator->two_factor_confirmed_at === null,
'twoFactorOn' => $operator->two_factor_confirmed_at !== null,
'twoFactorQr' => $operator->two_factor_secret !== null && $operator->two_factor_confirmed_at === null
? $operator->twoFactorQrCodeSvg()
: null,
'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
]);
}
}

View File

@ -1,402 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Host;
use App\Models\Operator;
use App\Models\VpnPeer;
use App\Provisioning\Jobs\ApplyVpnPeer;
use App\Provisioning\Jobs\SyncVpnPeers;
use App\Services\Wireguard\ConfigHandoff;
use App\Services\Wireguard\ConfigVault;
use App\Services\Wireguard\Keypair;
use App\Services\Wireguard\QrCode;
use App\Services\Wireguard\WireguardHub;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* VPN access management. The console container has no wg0, so live state is
* read from the database and refreshed by SyncVpnPeers on the provisioning
* queue the poll below only nudges that job, it never talks to WireGuard.
*/
#[Layout('layouts.admin')]
class Vpn extends Component
{
public string $name = '';
/** Optional: a peer that generated its own key never hands us the private half. */
public string $publicKey = '';
/** Opaque handle for the freshly created config; see ConfigHandoff. */
public ?string $configToken = null;
public ?string $newConfigName = null;
/** Owner of the new access. Defaults to the operator creating it. */
public ?int $ownerId = null;
/** Keep the config so its owner can fetch it again behind their password. */
public bool $storeConfig = false;
public bool $showQr = false;
public function mount(): void
{
$this->authorize('viewAny', VpnPeer::class);
$this->ownerId = Auth::guard('operator')->id();
}
/**
* mount() runs once; every later request including the five-second poll
* only hydrates. Without this, revoking vpn.manage would not take effect
* until the operator happened to reload the page, and the open tab would
* keep serving fresh peer state.
*/
public function hydrate(): void
{
$this->authorize('viewAny', VpnPeer::class);
}
public function create(): void
{
$this->authorize('create', VpnPeer::class);
$this->validate([
'name' => 'required|string|max:255',
'publicKey' => 'nullable|string|max:64',
// An access belongs to a person, and only to an operator: a customer
// account must never own a way into the management network — and
// now cannot even present as one, since operators live on their
// own table.
'ownerId' => ['required', Rule::exists('operators', 'id')],
]);
// Storing needs a key. Without one we would either write the credential
// in the clear or pretend we stored it — both worse than saying no.
if ($this->storeConfig && ! ConfigVault::available()) {
$this->addError('storeConfig', __('vpn.vault_unavailable'));
return;
}
$ownKey = trim($this->publicKey) !== '';
if ($this->storeConfig && $ownKey) {
$this->addError('storeConfig', __('vpn.cannot_store_foreign_key'));
return;
}
if ($ownKey && ! Keypair::isValidKey(trim($this->publicKey))) {
$this->addError('publicKey', __('vpn.invalid_key'));
return;
}
$keypair = $ownKey ? null : Keypair::generate();
$publicKey = $ownKey ? trim($this->publicKey) : $keypair->publicKey;
$hub = app(WireguardHub::class);
// Same lock the host pipeline holds while it reserves an address
// (ConfigureWireguard). Addresses come from one subnet but live in two
// tables, so neither unique index can catch the other's insert — the
// shared lock is what keeps a host and an access off the same tunnel IP.
try {
// Filled inside the transaction; only the handoff needs it afterwards.
$plainConfig = null;
$peer = Cache::lock('wireguard:allocate', 30)->block(10, function () use ($hub, $publicKey, $keypair, &$plainConfig) {
return DB::transaction(function () use ($hub, $publicKey, $keypair, &$plainConfig) {
// The owner row is locked for the whole insert, and revokeStaff()
// locks the same row: without that, a revocation could commit
// between the check and the insert, find no peer to remove, and
// leave the revoked colleague with a brand-new tunnel.
$owner = Operator::query()->whereKey($this->ownerId)->lockForUpdate()->first();
if ($owner === null || ! $owner->isOperator()) {
return 'owner_must_be_operator';
}
// Inside the lock: checking before it would let two concurrent
// requests both pass and the loser hit the unique index as a
// 500. withTrashed, because a revoked peer keeps its key until
// the hub confirms removal.
$existing = VpnPeer::withTrashed()->where('public_key', $publicKey)->first();
if ($existing !== null) {
return $existing->trashed() ? 'pending_removal' : 'duplicate_key';
}
// A host's key may not have been adopted into vpn_peers yet.
// Re-using it would make `wg set` rewrite that host's allowed-ip
// to the address allocated here — cutting the management tunnel
// to a live machine.
if (Host::query()->where('wg_pubkey', $publicKey)->exists()) {
return 'host_key';
}
$ip = $hub->allocateIp();
// Built and encrypted here so the stored config is part of the
// same insert. Doing it afterwards could leave a live access
// whose config was never stored — unrecoverable, and the
// operator would create a second one not knowing why.
$secret = null;
if ($keypair !== null) {
$plainConfig = $this->clientConfig($keypair, $ip);
if ($this->storeConfig) {
$secret = ConfigVault::encrypt($plainConfig);
}
}
return VpnPeer::create([
'name' => trim($this->name),
'kind' => VpnPeer::KIND_STAFF,
'user_id' => $this->ownerId,
'public_key' => $publicKey,
'allowed_ip' => $ip,
'config_secret' => $secret,
'enabled' => true,
'present' => false,
'created_by' => Auth::guard('operator')->id(),
]);
});
});
} catch (QueryException) {
// Backstop: the unique index caught a writer that did not take this
// lock. Report it like any other duplicate instead of a 500.
$this->addError('publicKey', __('vpn.duplicate_key'));
return;
}
if (is_string($peer)) {
$field = $peer === 'owner_must_be_operator' ? 'ownerId' : 'publicKey';
$this->addError($field, __('vpn.'.$peer));
return;
}
ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, true);
// Whatever was on screen belongs to the previous access. Leaving it up
// after creating another one would present the wrong private config
// under the new name — and someone would hand it out.
$this->dismissConfig();
// Only a key we generated can be turned into a ready-to-use config.
if ($plainConfig !== null) {
$this->configToken = ConfigHandoff::put($plainConfig);
$this->newConfigName = $peer->name;
}
$this->reset('name', 'publicKey', 'storeConfig');
$this->ownerId = Auth::guard('operator')->id();
$this->dispatch('notify', message: __('vpn.created'));
}
public function toggle(string $uuid): void
{
// Looked up globally and then judged by the policy: filtering it out of
// the query here would turn "not allowed" into a button that silently
// does nothing, which is exactly how the portal used to lie to people.
$peer = VpnPeer::query()->where('uuid', $uuid)->first();
if ($peer === null) {
return;
}
$this->authorize('block', $peer);
$peer->update(['enabled' => ! $peer->enabled]);
ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, $peer->enabled);
$this->dispatch('notify', message: $peer->enabled ? __('vpn.unblocked') : __('vpn.blocked'));
}
#[On('vpn-peer-deleted')]
public function peerDeleted(): void
{
$this->dispatch('notify', message: __('vpn.deleted'));
}
/**
* The reissue button opens ConfirmReissueVpnPeer instead of calling
* reissue() directly (R23); its confirm button dispatches back here.
*/
#[On('vpn-peer-reissue-confirmed')]
public function onReissueConfirmed(string $uuid): void
{
$this->reissue($uuid);
}
/**
* Replace an access's keypair.
*
* The way back for an access whose config was never stored: nobody can hand
* out a private key that no longer exists, so the honest option is a new
* one. The old key stops working the moment the hub is updated, which is
* also what makes this the right tool for a lost or leaked device.
*/
public function reissue(string $uuid): void
{
$peer = VpnPeer::query()->where('uuid', $uuid)->first();
if ($peer === null) {
return;
}
$this->authorize('update', $peer);
if ($peer->kind !== VpnPeer::KIND_STAFF) {
$this->dispatch('notify', message: __('vpn.reissue_staff_only'));
return;
}
$keypair = Keypair::generate();
$oldKey = $peer->public_key;
$config = $this->clientConfig($keypair, $peer->allowed_ip);
$peer->forceFill([
'public_key' => $keypair->publicKey,
'present' => false,
// Only re-stored when the vault can actually be used: without the key
// this would throw, and the console tells people to re-issue
// precisely when a stored config has become unreadable.
'config_secret' => $peer->hasStoredConfig() && ConfigVault::available()
? ConfigVault::encrypt($config)
: null,
])->save();
// Old key off the hub first, then the new one on: the other order would
// briefly leave two peers claiming the same tunnel address.
ApplyVpnPeer::dispatch($oldKey, null, false, force: true);
ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, true);
$this->dismissConfig();
$this->configToken = ConfigHandoff::put($config);
$this->newConfigName = $peer->name;
$this->dispatch('notify', message: __('vpn.reissued'));
}
public function dismissConfig(): void
{
ConfigHandoff::forget($this->configToken);
$this->reset('configToken', 'newConfigName', 'showQr');
}
/**
* The list this operator is allowed to see: their own accesses, plus
* everything when they hold vpn.view.all. Filtering in the query as well as
* in the actions, so a forged uuid cannot reach a peer the page never showed.
*/
private function visiblePeers()
{
$query = VpnPeer::query();
if (! Auth::guard('operator')->user()?->can('vpn.view.all')) {
$query->where('kind', VpnPeer::KIND_STAFF)->where('user_id', Auth::guard('operator')->id());
}
return $query;
}
public function toggleQr(): void
{
$this->showQr = ! $this->showQr;
}
/**
* wg-quick takes the interface name from the filename, and Linux caps
* interface names at 15 characters so the label is slugged and cut rather
* than handing the operator a file that fails to come up.
*/
public function configFilename(): string
{
$slug = Str::slug((string) $this->newConfigName) ?: 'clupilot';
return Str::limit($slug, 15, '').'.conf';
}
/** Polled by the view; throttled so a room full of open tabs cannot flood the queue. */
public function refreshPeers(): void
{
if (Cache::add('vpn:sync-dispatched', true, 8)) {
SyncVpnPeers::dispatch();
}
}
/**
* What the client sends through the tunnel: the management subnet, and only
* that.
*
* It is tempting to add the server's own public address here, because the
* console lives at a public hostname and without that route the phone sends
* the request out over the mobile network instead it arrives from a
* carrier address, the proxy refuses it, and the operator sees a blank page
* while the VPN app says "connected".
*
* That fix cannot work. The WireGuard endpoint is the SAME address. Routing
* it into the tunnel routes the handshake packets into the tunnel too, and
* a tunnel cannot carry the packets that establish it the result is a
* loop and a connection that never comes up at all. Strictly worse: the
* same symptom, now with no way in.
*
* The console has to be reachable at an address that is INSIDE the subnet
* for the VPN to be the way in. That is a deployment change (the proxy
* answering on the hub address, and a name that resolves to it), not
* something this config line can express.
*/
private static function allowedIps(string $endpoint): string
{
return (string) config('provisioning.wireguard.subnet', '10.66.0.0/24');
}
private function clientConfig(Keypair $keypair, string $ip): string
{
$hub = app(WireguardHub::class);
return implode("\n", [
'[Interface]',
'PrivateKey = '.$keypair->privateKey,
'Address = '.$ip.'/32',
// Only present when the console is actually reachable inside the
// tunnel. Setting it otherwise would hand the device a resolver
// that does not exist and take its name resolution with it.
...(config('admin_access.vpn_ready')
? ['DNS = '.config('provisioning.wireguard.hub_address', '10.66.0.1')]
: []),
'',
'[Peer]',
'PublicKey = '.$hub->publicKey(),
'Endpoint = '.$hub->endpoint(),
'AllowedIPs = '.self::allowedIps($hub->endpoint()),
'PersistentKeepalive = 25',
'',
]);
}
public function render()
{
$hub = app(WireguardHub::class);
return view('livewire.admin.vpn', [
'newConfig' => $config = ConfigHandoff::get($this->configToken),
'qrSvg' => $this->showQr && $config !== null ? QrCode::svg($config) : null,
'peers' => $this->visiblePeers()->with(['host', 'owner'])->orderByDesc('present')->orderBy('name')->get(),
'operators' => Operator::query()
->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::OPERATOR_ROLES))
->orderBy('name')->get(['id', 'name', 'email']),
'canManage' => Auth::guard('operator')->user()?->can('vpn.manage.all') ?? false,
'vaultAvailable' => ConfigVault::available(),
'hubEndpoint' => $hub->endpoint(),
'hubPublicKey' => $hub->publicKey(),
'lastSync' => VpnPeer::query()->max('observed_at'),
]);
}
}

View File

@ -1,140 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\ResolvesOperator;
use App\Models\VpnPeer;
use App\Services\Wireguard\ConfigHandoff;
use App\Services\Wireguard\ConfigVault;
use App\Services\Wireguard\QrCode;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use LivewireUI\Modal\ModalComponent;
/**
* Retrieving a stored VPN config again, behind the owner's own password.
*
* The password is asked for on EVERY retrieval, not once per session: Laravel's
* password.confirm middleware keeps a 15-minute session stamp, which would
* authorise unlimited later downloads from an unattended browser not what
* "enter your password to download it" means.
*/
class VpnConfigAccess extends ModalComponent
{
use ResolvesOperator;
public string $uuid;
public string $name = '';
public string $password = '';
/** Opaque handle; the plaintext never enters the component snapshot. */
public ?string $token = null;
public bool $showQr = false;
public function mount(string $uuid): void
{
$peer = VpnPeer::query()->where('uuid', $uuid)->firstOrFail();
$this->authorize('downloadConfig', $peer);
$this->uuid = $uuid;
$this->name = $peer->name;
}
/**
* Every later request re-checks: once revealed, the plaintext sits in the
* handoff cache for ten minutes, and an open modal would otherwise keep
* handing it out after the access was revoked or reassigned exactly the
* window purgeSecret() exists to close.
*/
public function hydrate(): void
{
$peer = VpnPeer::withTrashed()->where('uuid', $this->uuid)->first();
if ($peer === null || Gate::denies('downloadConfig', $peer)) {
ConfigHandoff::forget($this->token);
$this->token = null;
abort(403);
}
}
public function reveal(): void
{
// A retry must not still be showing the previous attempt's complaint.
$this->resetErrorBag('password');
$peer = VpnPeer::query()->where('uuid', $this->uuid)->firstOrFail();
$this->authorize('downloadConfig', $peer);
if (! $operator = $this->currentOperator()) {
return;
}
// Rate-limited per user, so a stolen session cannot grind the password.
$key = 'vpn-config:'.$operator->id;
if (RateLimiter::tooManyAttempts($key, 5)) {
$this->addError('password', __('vpn.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]));
return;
}
if (! Hash::check($this->password, $operator->password)) {
RateLimiter::hit($key, 300);
Log::warning('VPN config download refused: wrong password', [
'user_id' => $operator->id, 'peer' => $peer->uuid,
]);
$this->addError('password', __('vpn.wrong_password'));
return;
}
RateLimiter::clear($key);
$plaintext = ConfigVault::decrypt((string) $peer->config_secret);
if ($plaintext === null) {
// Wrong or rotated key, or a tampered record. Never guess.
$this->addError('password', __('vpn.config_unreadable'));
return;
}
// Counted in the database, not read-modify-written here: two concurrent
// retrievals would otherwise record one. This is an audit trail, so
// under-reporting is the one failure mode it must not have.
VpnPeer::query()->whereKey($peer->getKey())->update([
'last_downloaded_at' => now(),
'download_count' => DB::raw('download_count + 1'),
]);
Log::info('VPN config downloaded', ['user_id' => $operator->id, 'peer' => $peer->uuid]);
$this->reset('password');
$this->token = ConfigHandoff::put($plaintext);
}
public function toggleQr(): void
{
$this->showQr = ! $this->showQr;
}
public function filename(): string
{
return Str::limit(Str::slug($this->name) ?: 'clupilot', 15, '').'.conf';
}
public function render()
{
$config = ConfigHandoff::get($this->token);
return view('livewire.admin.vpn-config-access', [
'config' => $config,
'qrSvg' => $config !== null && $this->showQr ? QrCode::svg($config) : null,
]);
}
}

View File

@ -1,15 +0,0 @@
<?php
namespace App\Livewire\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal')]
class Login extends Component
{
public function render()
{
return view('livewire.auth.login');
}
}

View File

@ -1,127 +0,0 @@
<?php
namespace App\Livewire\Auth;
use App\Models\Operator;
use App\Support\AdminArea;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The console's own sign-in.
*
* Not Fortify: Fortify binds to exactly one guard, and hanging both the portal
* and the console off it would be the shared login this separation exists to
* end. The two-factor MECHANICS are still Fortify's the same
* TwoFactorAuthenticatable trait and the same TOTP provider only the flow is
* ours.
*/
#[Layout('layouts.portal')]
class OperatorLogin extends Component
{
public string $email = '';
public string $password = '';
public bool $remember = false;
public function authenticate(): void
{
// Rules on the action: a #[Validate] attribute on a property applies
// class-wide and would be dragged into any other action added later.
//
// password is NOT 'required': a failed attempt resets it below, and a
// retry (or a brute-force loop) submitting on top of that empty value
// must still count against the rate limiter instead of stopping short
// at a validation error keyed 'password' — which would never accumulate
// toward the throttle at all, five wrong-password submissions in a row
// would never actually throttle. An empty password fails Hash::check()
// like any other wrong one and gets the same generic message.
$this->validate([
'email' => ['required', 'email'],
'password' => ['string'],
]);
$key = 'operator-login:'.mb_strtolower($this->email).'|'.request()->ip();
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'email' => __('auth.throttle', ['seconds' => RateLimiter::availableIn($key)]),
]);
}
$operator = Operator::where('email', $this->email)->first();
// One message for every failure — a different message for "no such
// account" tells a stranger which addresses are operators.
if ($operator === null
|| ! Hash::check($this->password, $operator->password)
|| ! $operator->isActive()
|| ! $operator->isOperator()) {
RateLimiter::hit($key, 60);
$this->reset('password');
throw ValidationException::withMessages(['email' => __('auth.failed')]);
}
RateLimiter::clear($key);
if ($operator->two_factor_confirmed_at !== null) {
// Not signed in yet. The id is parked in the session and the guard
// stays untouched until the code checks out.
session(['operator.2fa.id' => $operator->id, 'operator.2fa.remember' => $this->remember]);
$this->redirectRoute('admin.two-factor', navigate: false);
return;
}
$this->completeLoginAndRedirect($operator, $this->remember);
}
/**
* Shared with the two-factor challenge, so both exits behave identically.
*
* Re-checks isActive()/isOperator() here, not only at the password step:
* an operator can be disabled, or lose every console role, in the window
* between entering a password and completing a PENDING two-factor
* challenge an owner revoking access while someone is mid-login is the
* realistic case, not a contrived one, and the challenge on its own only
* ever confirmed the row still existed. One check, in the one place both
* entry points already share, rather than a second copy in the challenge
* that the next change forgets to keep in sync.
*
* @return bool False when the operator no longer qualifies: the login is
* refused, not completed neither the guard nor the
* session is touched, and the caller decides how to say so.
*/
public static function completeLogin(Operator $operator, bool $remember): bool
{
if (! $operator->isActive() || ! $operator->isOperator()) {
return false;
}
Auth::guard('operator')->login($operator, $remember);
session()->regenerate();
$operator->forceFill(['last_login_at' => now()])->save();
return true;
}
private function completeLoginAndRedirect(Operator $operator, bool $remember): void
{
if (! self::completeLogin($operator, $remember)) {
throw ValidationException::withMessages(['email' => __('auth.failed')]);
}
$this->redirect(AdminArea::home(), navigate: false);
}
public function render()
{
return view('livewire.auth.operator-login');
}
}

View File

@ -1,102 +0,0 @@
<?php
namespace App\Livewire\Auth;
use App\Models\Operator;
use App\Support\AdminArea;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The operator's two-factor step.
*
* Fortify's TOTP provider does the arithmetic there is no second
* implementation of the algorithm here, only a second flow, because Fortify's
* own flow is bound to the web guard.
*/
#[Layout('layouts.portal')]
class OperatorTwoFactorChallenge extends Component
{
public string $code = '';
public string $recoveryCode = '';
public bool $usingRecovery = false;
public function mount(): void
{
// Reachable only between password and code. Landing here directly is a
// dead end, not a way in.
abort_if(! session()->has('operator.2fa.id'), 403);
}
public function verify(): void
{
$operator = Operator::find(session('operator.2fa.id'));
abort_if($operator === null, 403);
$key = 'operator-2fa:'.$operator->id.'|'.request()->ip();
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'code' => __('auth.throttle', ['seconds' => RateLimiter::availableIn($key)]),
]);
}
if (! $this->passes($operator)) {
RateLimiter::hit($key, 60);
$this->reset('code', 'recoveryCode');
throw ValidationException::withMessages(['code' => __('auth.twofa_invalid')]);
}
RateLimiter::clear($key);
$remember = (bool) session('operator.2fa.remember', false);
session()->forget(['operator.2fa.id', 'operator.2fa.remember']);
if (! OperatorLogin::completeLogin($operator, $remember)) {
// Disabled, or lost every console role, in the window between
// the password step and this one. The code just entered may be
// perfectly correct — the account behind it no longer is, so
// reusing "wrong code" would mislead. The pending challenge is
// spent either way (already forgotten above): no retry loop
// against a session that can never complete now.
throw ValidationException::withMessages(['code' => __('auth.not_an_operator')]);
}
$this->redirect(AdminArea::home(), navigate: false);
}
private function passes(Operator $operator): bool
{
if ($this->usingRecovery) {
$codes = json_decode(decrypt($operator->two_factor_recovery_codes), true) ?: [];
if (! in_array($this->recoveryCode, $codes, true)) {
return false;
}
// A recovery code is single use — leaving it valid turns a one-time
// escape hatch into a second password.
$operator->forceFill([
'two_factor_recovery_codes' => encrypt(json_encode(array_values(
array_diff($codes, [$this->recoveryCode])
))),
])->save();
return true;
}
return app(TwoFactorAuthenticationProvider::class)
->verify(decrypt($operator->two_factor_secret), $this->code);
}
public function render()
{
return view('livewire.auth.operator-two-factor-challenge');
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Livewire\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Full-page signup view (R1/R2). The POST is handled by Fortify's registration
* feature (register.store App\Actions\Fortify\CreateNewUser).
*/
#[Layout('layouts.portal')]
class Register extends Component
{
public function render()
{
return view('livewire.auth.register');
}
}

View File

@ -1,15 +0,0 @@
<?php
namespace App\Livewire\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal')]
class TwoFactorChallenge extends Component
{
public function render()
{
return view('livewire.auth.two-factor-challenge');
}
}

View File

@ -1,88 +0,0 @@
<?php
namespace App\Livewire\Auth;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Where somebody waits between registering and confirming.
*
* Signed in but not through: the account exists, and nothing in the portal
* opens until the address is confirmed. The page therefore has to offer the
* only two things that can move somebody forward send it again, or sign out
* and register with the address they meant.
*
* Fortify would own this page, but 'views' => false: the app renders its own
* screens (R1/R2) and Fortify keeps only the POST actions.
*/
#[Layout('layouts.portal')]
class VerifyEmail extends Component
{
/**
* Six a minute is generous for a person and useless for anything else.
* Without it this is a button that makes the server send mail to an
* arbitrary address as fast as it can be clicked.
*/
private const PER_MINUTE = 6;
/**
* Somebody already through has no step left, and telling them they do is
* how a confirmed account goes looking for a mail it does not need. The
* `verified` middleware only guards the other direction it keeps the
* unconfirmed out of the portal and has nothing to say about this page.
*/
public function mount()
{
if (Auth::user()?->hasVerifiedEmail()) {
return $this->redirectRoute('dashboard', navigate: false);
}
return null;
}
public function resend(): void
{
$user = Auth::user();
// Already through — a stale tab, or the link opened in another window.
// Sending another confirmation for an address that no longer needs one
// is how somebody ends up chasing a mail they do not need.
if ($user === null || $user->hasVerifiedEmail()) {
$this->redirectRoute('dashboard', navigate: true);
return;
}
$key = 'verify-resend:'.$user->getKey();
if (RateLimiter::tooManyAttempts($key, self::PER_MINUTE)) {
$this->dispatch('notify', message: __('verify_email.notice_hint'));
return;
}
RateLimiter::hit($key, 60);
$user->sendEmailVerificationNotification();
$this->dispatch('notify', message: __('verify_email.notice_sent'));
}
public function signOut()
{
Auth::guard('web')->logout();
session()->invalidate();
session()->regenerateToken();
return $this->redirectRoute('login', navigate: false);
}
public function render()
{
return view('livewire.auth.verify-email', [
'email' => Auth::user()?->getEmailForVerification() ?? '',
]);
}
}

View File

@ -1,58 +0,0 @@
<?php
namespace App\Livewire;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Backups extends Component
{
public function render()
{
$locale = app()->getLocale();
$gb = fn (float $v) => Number::format($v, precision: 1, locale: $locale).' GB';
$date = fn (string $iso, string $fmt) => Carbon::parse($iso)->local()->locale($locale)->isoFormat($fmt);
// 14 days of backup sizes for the bar chart.
$sizes = [18.1, 18.1, 18.2, 18.2, 18.2, 18.3, 18.3, 18.3, 18.3, 18.4, 18.4, 18.3, 18.4, 18.4];
$rows = [];
foreach (range(0, 6) as $i) {
$rows[] = [
'when' => $date(Carbon::parse('2026-07-24')->subDays($i)->toDateString(), 'dd, D. MMMM').', 03:1'.($i % 3 + 1),
'size' => $gb(18.4 - $i * 0.02),
'status' => 'ok',
];
}
return view('livewire.backups', [
'rows' => $rows,
'lastTest' => $date('2026-07-01', 'LL'),
'sizeChart' => [
'type' => 'line',
'data' => [
'labels' => array_map(fn ($i) => $date(Carbon::parse('2026-07-24')->subDays(13 - $i)->toDateString(), 'D.'), range(0, 13)),
'datasets' => [[
'label' => 'GB',
'data' => $sizes,
'borderColor' => 'token:accent',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
'borderWidth' => 2,
]],
],
'options' => [
'scales' => [
'x' => ['grid' => ['display' => false]],
'y' => ['beginAtZero' => false, 'grid' => ['color' => 'token:border']],
],
'plugins' => ['legend' => ['display' => false]],
],
],
]);
}
}

View File

@ -1,204 +0,0 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use App\Models\Order;
use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\DowngradeCheck;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Billing extends Component
{
use ResolvesCustomer;
/**
* Record a purchase intent (order). Fulfillment (Stripe checkout + resize) is
* mocked for now the order is created with status 'pending'.
*/
public function purchase(string $type, ?string $key = null): void
{
$customer = $this->requireCustomer();
if ($customer === null) {
return;
}
$instance = $customer->instances()->latest('id')->first();
$currentPlan = $instance?->plan ?? 'start';
$plans = app(PlanCatalogue::class)->sellable();
$addons = (array) config('provisioning.addons');
[$plan, $amount, $addonKey] = match ($type) {
'upgrade', 'downgrade' => [$key, (int) ($plans[$key]['price_cents'] ?? 0), null],
'storage' => [$currentPlan, (int) config('provisioning.storage_addon.price_cents', 0), null],
'traffic' => [$currentPlan, (int) config('provisioning.traffic.addon.price_cents', 0), null],
'addon' => [$currentPlan, (int) ($addons[$key]['price_cents'] ?? 0), $key],
default => [null, 0, null],
};
// Guard against invalid keys / non-upgrades.
$valid = match ($type) {
// By rank, matching PlanChange and the cards on the page. Comparing
// prices would call a grandfathered plan an upgrade to a smaller
// one and charge for the privilege.
'upgrade' => isset($plans[$key])
&& (int) ($plans[$key]['tier'] ?? 0) > (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0),
// Re-checked here and not only in the view: the button can be
// hidden and the action still called — a stale tab, a second
// window, anyone with the component name. A limit enforced only in
// markup is not enforced.
'downgrade' => isset($plans[$key])
&& (int) ($plans[$key]['tier'] ?? 0) < (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0)
&& DowngradeCheck::for($customer, $instance, $plans[$key])->allowed,
'storage' => true,
// Always available: running out of traffic is exactly when someone
// needs to be able to buy more, whatever plan they are on.
'traffic' => true,
'addon' => isset($addons[$key]),
default => false,
};
if (! $valid || $plan === null) {
return;
}
$datacenter = $instance?->host?->datacenter
?? $customer->orders()->latest('id')->value('datacenter')
?? 'fsn';
// A cart cannot hold two plan changes: they contradict each other and no
// checkout could resolve which one the customer meant. Choosing another
// replaces the pending one — add-ons still stack.
//
// Replacement and insert run together with the customer row locked:
// two clicks in flight could otherwise both delete and then both
// insert, leaving exactly the two upgrades this rule exists to prevent.
$replaced = DB::transaction(function () use ($customer, $type, $plan, $addonKey, $amount, $datacenter) {
Customer::query()->whereKey($customer->id)->lockForUpdate()->first();
// Up and down are the same kind of change and contradict each
// other just as much, so either replaces a pending one of both.
$replaced = in_array($type, ['upgrade', 'downgrade'], true)
? Order::query()
->where('customer_id', $customer->id)
->where('status', 'pending')
->whereIn('type', ['upgrade', 'downgrade'])
->delete()
: 0;
Order::create([
'customer_id' => $customer->id,
'plan' => $plan,
'type' => $type,
'addon_key' => $addonKey,
'amount_cents' => $amount,
'currency' => 'EUR',
'datacenter' => $datacenter,
'status' => 'pending',
]);
return $replaced;
});
if ($replaced > 0) {
$this->dispatch('notify', message: __('billing.cart.plan_replaced'));
}
$this->dispatch('notify', message: __('billing.purchased'));
}
#[\Livewire\Attributes\On('order-removed')]
public function orderRemoved(): void
{
$this->dispatch('notify', message: __('billing.cart.removed'));
}
/**
* The terms to show as "your plan": the contract if there is one, and only
* otherwise the catalogue someone browsing before they have bought.
*
* @param array<string, mixed> $catalogue
* @return array<string, mixed>
*/
private function currentTerms(?Subscription $subscription, array $catalogue): array
{
if ($subscription === null) {
return $catalogue;
}
return [
'tier' => $subscription->tier,
// Per month: the card says "/ month", and a yearly contract stores
// the whole year.
'price_cents' => $subscription->monthlyPriceCents(),
'currency' => $subscription->currency,
'quota_gb' => $subscription->quota_gb,
'traffic_gb' => $subscription->traffic_gb,
'seats' => $subscription->seats,
'performance' => $subscription->performance,
'features' => $subscription->planVersion?->features ?? ($catalogue['features'] ?? []),
];
}
public function render()
{
$customer = $this->customer();
$instance = $customer?->instances()->latest('id')->first();
$plans = app(PlanCatalogue::class)->sellable();
$currentKey = $instance?->plan ?? 'start';
// Rank, not price: a grandfathered plan can cost less than a smaller
// one does today, and offering that as an "upgrade" would charge a
// customer immediately for losing resources.
$currentTier = (int) ($instance?->subscription?->tier ?? $plans[$currentKey]['tier'] ?? 0);
// Smaller plans, each with the reason it cannot be taken today. Shown
// WITH the reason rather than hidden: a customer who cannot downgrade
// needs to know what to delete, and a plan that silently disappears
// reads as "not offered any more".
$downgrades = collect($plans)
->filter(fn ($p) => (int) ($p['tier'] ?? 0) < $currentTier)
->sortByDesc(fn ($p) => (int) ($p['tier'] ?? 0))
->map(fn ($p, $k) => $p + ['check' => DowngradeCheck::for($customer, $instance, $p)])
->all();
$upgrades = collect($plans)
->filter(fn ($p) => (int) ($p['tier'] ?? 0) > $currentTier)
->keys()->all();
return view('livewire.billing', [
'currentKey' => $currentKey,
// What they HAVE comes from their contract; only what they could
// BUY comes from the shop. Reading this off the catalogue showed a
// customer today's price as though it were theirs, and dropped the
// card entirely once their plan stopped being sold.
'current' => $this->currentTerms($instance?->subscription, $plans[$currentKey] ?? []),
'instance' => $instance,
'plans' => $plans,
'features' => collect($plans)->map(fn ($p) => $p['features'] ?? [])->all(),
'upgrades' => $upgrades,
'downgrades' => $downgrades,
'storage' => (array) config('provisioning.storage_addon'),
'trafficAddon' => (array) config('provisioning.traffic.addon'),
// Resolved once: the cart and the plan cards must not state
// different tax treatments on the same page.
'tax' => \App\Services\Billing\TaxTreatment::for($customer),
'trafficMeter' => $instance !== null ? \App\Services\Traffic\TrafficMeter::for($instance) : null,
// Booked modules at the price they were booked at; everything else
// at today's. Reading them all off the catalogue would re-price
// half a customer's bill behind their back.
'addons' => collect(app(AddonCatalogue::class)->forSubscription($instance?->subscription))
->except(AddonCatalogue::STORAGE)
->all(),
'totalMonthlyCents' => $instance?->subscription?->totalMonthlyCents(),
'pending' => $customer
? $customer->orders()->where('status', 'pending')->latest('id')->get()
: collect(),
]);
}
}

View File

@ -1,89 +0,0 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\MaintenanceWindow;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Cloud extends Component
{
use ResolvesCustomer;
public function render()
{
$locale = app()->getLocale();
$growth = [180, 188, 195, 199, 204, 210, 214, 219, 223, 228, 231, 235];
// The card is built from the customer's real service instance, so the
// maintenance badge below can be scoped to exactly that instance's host.
$customer = $this->customer();
$shown = $customer?->instances()
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->latest('id')->first();
$maintenance = MaintenanceWindow::forInstance($shown)->first();
// The instance carries what was actually provisioned, and the contract
// what was bought. Neither is the catalogue, which only describes what
// we would sell someone new — never what this customer already has.
$contract = $shown?->subscription;
$planKey = $shown?->plan ?? 'team';
$quota = (int) ($shown?->quota_gb ?? $contract?->quota_gb ?? 500);
// Usage metering is not wired yet — scale the illustrative curve into the
// instance's quota so the chart and the "x / y GB" label never disagree.
$curveMax = max($growth);
if ($curveMax > $quota) {
$growth = array_map(fn ($v) => (int) round($v / $curveMax * $quota), $growth);
}
$used = $growth[count($growth) - 1];
$domain = $shown?->custom_domain
?: ($shown?->subdomain ? $shown->subdomain.'.'.config('provisioning.dns.zone', 'clupilot.com') : 'cloud.example.com');
return view('livewire.cloud', [
'instance' => [
'name' => $customer ? __('cloud.instance_name', ['name' => $customer->name]) : __('cloud.title'),
'domain' => $domain,
'status' => $shown->status ?? 'active',
'plan' => __('cloud.plan_line', [
'plan' => 'CluPilot Cloud '.__('billing.plan.'.$planKey),
// The line ends in "/mo", so a yearly contract has to be
// divided down before it goes in.
'price' => (int) round(($contract?->monthlyPriceCents() ?? 0) / 100),
]),
'location' => __('cloud.datacenter'),
'storageUsed' => $used,
'storageQuota' => $quota,
'seats' => __('billing.seats_count', ['count' => $contract?->seats ?? 0]),
'performance' => __('billing.perf.'.($contract?->performance ?? 'standard')),
],
'storageChart' => [
'type' => 'line',
'data' => [
'labels' => ['', '', '', '', '', '', '', '', '', '', '', __('cloud.now')],
'datasets' => [[
'label' => 'GB',
'data' => $growth,
'borderColor' => 'token:accent',
'backgroundColor' => 'token:accent/0.10',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
'borderWidth' => 2,
]],
],
'options' => [
'scales' => [
'x' => ['grid' => ['display' => false]],
'y' => ['beginAtZero' => false, 'grid' => ['color' => 'token:border']],
],
'plugins' => ['legend' => ['display' => false]],
],
],
'storageLabel' => Number::format($used, locale: $locale).' / '.Number::format($quota, locale: $locale).' GB',
'maintenance' => $maintenance,
]);
}
}

View File

@ -1,56 +0,0 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\ProvisioningRun;
/**
* Builds the {label, state} array a progress stepper renders from a run's
* pipeline + current step + status. Shared by admin and customer views.
*/
trait BuildsRunSteps
{
/** @return array<int, array{label: string, state: string}> */
protected function buildRunSteps(?ProvisioningRun $run): array
{
if ($run === null) {
return [];
}
$pipeline = config('provisioning.pipelines.'.$run->pipeline, []);
$current = $run->current_step;
$status = $run->status;
$steps = [];
foreach ($pipeline as $index => $class) {
if ($status === ProvisioningRun::STATUS_COMPLETED || $index < $current) {
$state = 'done';
} elseif ($index === $current) {
$state = $status === ProvisioningRun::STATUS_FAILED ? 'failed' : 'running';
} else {
$state = 'pending';
}
$steps[] = ['label' => __(app($class)->label()), 'state' => $state];
}
return $steps;
}
protected function currentStepLabel(ProvisioningRun $run): string
{
$pipeline = config('provisioning.pipelines.'.$run->pipeline, []);
$class = $pipeline[$run->current_step] ?? null;
return $class ? __(app($class)->label()) : '—';
}
protected function runState(ProvisioningRun $run): string
{
return match ($run->status) {
ProvisioningRun::STATUS_COMPLETED => 'done',
ProvisioningRun::STATUS_FAILED => 'failed',
default => 'running',
};
}
}

View File

@ -1,106 +0,0 @@
<?php
namespace App\Livewire\Concerns;
use App\Actions\Fortify\PasswordValidationRules;
use App\Actions\Fortify\UpdateUserPassword;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
/**
* Changing your own password, from wherever you are signed in.
*
* There was no way to do it at all: Fortify's updatePasswords feature was off,
* so an account created with a generated password kept it until someone opened
* a shell on the server. Shared by the console and the customer portal because
* the rules are the same in both, and having two copies of a password rule is
* how one of them ends up weaker.
*
* Goes through Fortify's own action rather than hashing here, so the password
* rules stay in the single place that already defines them on the `web`
* guard, which is the only one UpdateUserPassword knows: it is typed to
* `User` and pins its own `current_password` rule to `web`. An operator
* validates against the SAME PasswordValidationRules and writes its own hash
* here instead, rather than routing through an action written for a
* different model on a different guard.
*/
trait ChangesOwnPassword
{
use PasswordValidationRules;
public string $currentPassword = '';
public string $newPassword = '';
public string $newPasswordConfirmation = '';
/**
* Which guard's account this component changes the password of.
*
* Overridden to 'operator' by the console's Settings page. A default of
* 'web' keeps the portal working unchanged.
*/
protected function passwordAccountGuard(): string
{
return 'web';
}
public function updateOwnPassword(): void
{
// The current password is asked for, and checked here as well as in the
// action: an attacker on an unlocked machine should not be able to lock
// the owner out of their own account with two keystrokes.
$this->validate([
'currentPassword' => 'required|string',
'newPassword' => 'required|string',
'newPasswordConfirmation' => 'required|string',
]);
$guard = $this->passwordAccountGuard();
$account = Auth::guard($guard)->user();
if (! Hash::check($this->currentPassword, $account->password)) {
$this->addError('currentPassword', __('admin_settings.password_wrong'));
return;
}
if ($guard === 'web') {
try {
app(UpdateUserPassword::class)->update($account, [
'current_password' => $this->currentPassword,
'password' => $this->newPassword,
'password_confirmation' => $this->newPasswordConfirmation,
]);
} catch (ValidationException $e) {
foreach ($e->errors() as $field => $messages) {
$this->addError(match ($field) {
'current_password' => 'currentPassword',
'password' => 'newPassword',
default => $field,
}, $messages[0]);
}
return;
}
} else {
try {
Validator::make([
'password' => $this->newPassword,
'password_confirmation' => $this->newPasswordConfirmation,
], ['password' => $this->passwordRules()])->validate();
} catch (ValidationException $e) {
$this->addError('newPassword', $e->errors()['password'][0]);
return;
}
$account->forceFill(['password' => Hash::make($this->newPassword)])->save();
}
$this->reset('currentPassword', 'newPassword', 'newPasswordConfirmation');
$this->dispatch('notify', message: __('admin_settings.password_changed'));
}
}

View File

@ -1,135 +0,0 @@
<?php
namespace App\Livewire\Concerns;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
/**
* A password re-entry gate in front of the dangerous parts of a page.
*
* Used by anything where holding a signed-in session should not be enough on
* its own: setting up two-factor authentication, and reading or changing stored
* credentials. The threat is not a stranger it is a colleague, or anyone, at
* an unlocked machine.
*
* The marker is keyed on the confirming GUARD and that guard's own ACCOUNT id
* (passwordConfirmationSessionKey()) it used to be one flat key shared by
* every guard, the same one Laravel's own `password.confirm` middleware
* uses. That worked only as long as one session ever held one identity. In
* shared-host mode (this VM's own mode) the portal and console guards
* coexist in the SAME session, so a customer confirming their portal
* password and an operator confirming an operator-only action both wrote and
* read that identical flat key confirming as one identity silently
* satisfied the gate for the other (Codex R15, P1a). Scoping to guard AND
* identity closes both halves at once: a marker cannot cross a guard
* boundary, and cannot survive switching to a different account on the same
* guard either. No framework route in this app runs Laravel's own
* password.confirm middleware (checked) if that changes, it needs the same
* scoping or this gap reopens.
*
* Rate-limited, because a confirmation form is a password oracle: without a
* limit it is an unauthenticated-feeling place to guess at a known account.
*/
trait ConfirmsPassword
{
public string $confirmablePassword = '';
/** How long one confirmation lasts. Laravel's own default. */
protected function confirmationWindow(): int
{
return (int) config('auth.password_timeout', 10800);
}
public function passwordRecentlyConfirmed(): bool
{
$at = (int) session($this->passwordConfirmationSessionKey(), 0);
return $at > 0 && (time() - $at) < $this->confirmationWindow();
}
/**
* Which guard's record this component confirms against.
*
* Overridden to 'operator' by the console components. A default of 'web'
* keeps the portal working unchanged and an override is explicit, where
* sniffing the request would be one more thing to get wrong on a page that
* gates access to credentials.
*/
protected function confirmationGuard(): string
{
return 'web';
}
public function confirmPassword(): void
{
$account = auth()->guard($this->confirmationGuard())->user();
$key = 'confirm-password:'.$this->confirmationGuard().'|'.$account?->getAuthIdentifier().'|'.request()->ip();
if (RateLimiter::tooManyAttempts($key, 5)) {
$this->addError('confirmablePassword', __('auth.throttle', [
'seconds' => RateLimiter::availableIn($key),
]));
return;
}
if ($account === null || ! Hash::check($this->confirmablePassword, $account->password)) {
RateLimiter::hit($key, 60);
$this->addError('confirmablePassword', __('admin_settings.password_wrong'));
$this->reset('confirmablePassword');
return;
}
RateLimiter::clear($key);
$this->reset('confirmablePassword');
// Regenerated first: a confirmation raises what this session can do, and
// a session id that was already known to someone else must not be the
// one that gets the extra authority.
session()->regenerate();
session([$this->passwordConfirmationSessionKey() => time()]);
}
/** Give up the confirmation early — leaving a page should not keep it open. */
public function forgetPasswordConfirmation(): void
{
session()->forget($this->passwordConfirmationSessionKey());
}
/**
* The one place this marker's session key is computed.
*
* passwordRecentlyConfirmed(), confirmPassword() and
* forgetPasswordConfirmation() all go through this rather than each
* building the string themselves, so they cannot drift apart from one
* another which is exactly how the guard-agnostic key this replaces
* came to be read by one guard and written by another.
*/
private function passwordConfirmationSessionKey(): string
{
$guard = $this->confirmationGuard();
$identity = auth()->guard($guard)->user()?->getAuthIdentifier() ?? 'guest';
return "auth.password_confirmed_at.{$guard}.{$identity}";
}
/**
* A value shown only in outline: the last four characters, and nothing else.
*
* Enough to tell two keys apart, useless to anyone reading over a shoulder
* or scrolling back through a screen recording.
*/
protected function outline(?string $value, int $keep = 4): ?string
{
if ($value === null || $value === '') {
return null;
}
return Str::length($value) <= $keep
? str_repeat('•', Str::length($value))
: str_repeat('•', 8).Str::substr($value, -$keep);
}
}

View File

@ -1,39 +0,0 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\Customer;
/**
* Resolves the customer behind the signed-in portal user.
*
* Operator accounts (admins) have no Customer, so customer-scoped actions must
* NOT silently no-op that reads as a dead button. requireCustomer() surfaces
* an explicit message instead and returns null so the caller can bail.
*/
trait ResolvesCustomer
{
protected function customer(): ?Customer
{
$user = auth()->user();
if (! $user) {
return null;
}
// Prefer the explicit link; fall back to email for legacy unlinked accounts.
return Customer::query()->where('user_id', $user->id)->first()
?? Customer::query()->where('email', $user->email)->first();
}
/** Same as customer(), but tells the user why nothing happened. */
protected function requireCustomer(): ?Customer
{
$customer = $this->customer();
if ($customer === null) {
$this->dispatch('notify', message: __('dashboard.no_customer_action'));
}
return $customer;
}
}

View File

@ -1,52 +0,0 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\Operator;
use Illuminate\Support\Facades\Auth;
/**
* The one place a console Livewire component resolves "who is signed in"
* and decides what happens when the answer is nobody.
*
* A Livewire action reaches its component through its own request to
* /livewire/update, separate from the request that first rendered the page
* a modal especially, reachable without the page's own route middleware (see
* R20 in CLAUDE.md). An operator whose session has ended by the time an
* action runs still reaches this code, and Auth::guard('operator')->user()
* comes back null. Four components each dereferenced it unguarded
* ($operator->password, ->id, ->two_factor_recovery_codes, ), turning that
* into a 500 instead of a sign-in prompt.
*
* currentOperator() cannot throw its way out of the null case. Livewire only
* turns $this->redirect() into a browser navigation once the calling action
* returns NORMALLY: the redirect is queued as component state and picked up
* during Livewire's own dehydrate step (Livewire\Features\SupportRedirects),
* which runs strictly after the called method returns an uncaught
* exception during the call skips it entirely and Livewire ships back a bare
* error response instead (confirmed by reading
* Livewire\Mechanisms\HandleComponents::update() and
* Livewire\Mechanisms\HandleRequests::handleUpdate(), which catches nothing
* but \TypeError). So this returns null and every caller checks for it:
*
* if (! $operator = $this->currentOperator()) {
* return;
* }
*
* That one-line guard is the only thing left for a caller to get wrong; the
* redirect target and the mechanism live here once, so the four cannot drift
* apart the way the original unguarded dereferences did.
*/
trait ResolvesOperator
{
protected function currentOperator(): ?Operator
{
$operator = Auth::guard('operator')->user();
if ($operator === null) {
$this->redirect(route('admin.login'));
}
return $operator;
}
}

View File

@ -1,78 +0,0 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Instance;
use LivewireUI\Modal\ModalComponent;
/**
* Cancel the customer's package (R5). Per the founder's decision: effective at
* the end of the billing term, irreversible once confirmed; at term end the
* customer receives a finished data export, then the instance is deprovisioned
* (both mocked for now). Requires typing the instance subdomain to confirm.
*/
class ConfirmCancelPackage extends ModalComponent
{
use ResolvesCustomer;
public string $confirmName = '';
public function cancelPackage()
{
$customer = $this->customer();
// Target the ACTIVE package explicitly — a newer failed/deprovisioned
// record must not shadow an older active instance (v1: one active/customer).
$instance = $customer?->instances()->where('status', 'active')->latest('id')->first();
if ($instance === null) {
// Explain rather than silently closing: no customer behind this login
// (e.g. an operator account) or no active package to cancel.
$this->addError('confirmName', __($customer === null ? 'dashboard.no_customer_action' : 'settings.no_package'));
return null;
}
// Typed confirmation must match the instance subdomain.
if (trim($this->confirmName) !== (string) $instance->subdomain) {
$this->addError('confirmName', __('settings.cancel_mismatch'));
return;
}
$instance->update([
'status' => 'cancellation_scheduled',
'cancel_requested_at' => now(),
'service_ends_at' => $this->currentPeriodEnd($instance),
]);
return $this->redirectRoute('settings', navigate: true);
}
/**
* End of the current monthly billing period, anchored on the subscription
* start (the order date) rather than assuming calendar-month billing.
*/
private function currentPeriodEnd(Instance $instance): \Illuminate\Support\Carbon
{
$start = $instance->order?->created_at ?? $instance->created_at ?? now();
// Always add from the immutable start so the billing-day anchor is kept
// (chaining addMonth would drift a Jan-31 start to Feb-28 → Mar-28).
$months = 1;
while ($start->copy()->addMonthsNoOverflow($months)->lessThanOrEqualTo(now())) {
$months++;
}
return $start->copy()->addMonthsNoOverflow($months);
}
public function render()
{
$instance = $this->customer()?->instances()->where('status', 'active')->latest('id')->first();
return view('livewire.confirm-cancel-package', [
'subdomain' => $instance?->subdomain ?? '',
]);
}
}

View File

@ -1,65 +0,0 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use LivewireUI\Modal\ModalComponent;
/**
* Close the whole CluPilot account (R5). Guarded: only allowed when no active
* package remains an active subscription must be cancelled first. Sets
* closed_at; financial records are retained. Requires typing "LOESCHEN".
*/
class ConfirmCloseAccount extends ModalComponent
{
use ResolvesCustomer;
public string $confirmWord = '';
public function closeAccount()
{
$customer = $this->customer();
// No customer behind this login (e.g. an operator account) — say so
// instead of leaving the button looking broken.
if ($customer === null) {
$this->addError('confirmWord', __('dashboard.no_customer_action'));
return null;
}
if ($this->hasActivePackage($customer)) {
$this->addError('confirmWord', __('settings.close_blocked'));
return;
}
if (mb_strtoupper(trim($this->confirmWord)) !== __('settings.close_keyword')) {
$this->addError('confirmWord', __('settings.close_mismatch'));
return;
}
$customer->update(['closed_at' => now(), 'status' => 'closed']);
return $this->redirectRoute('settings', navigate: true);
}
private function hasActivePackage(Customer $customer): bool
{
// Only genuinely-live packages block closure — a failed/cancelled/
// deprovisioned instance does not retain a service.
return $customer->instances()
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->exists();
}
public function render()
{
$customer = $this->customer();
return view('livewire.confirm-close-account', [
'blocked' => $customer !== null && $this->hasActivePackage($customer),
]);
}
}

View File

@ -1,24 +0,0 @@
<?php
namespace App\Livewire;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a customer's own two-factor sign-in is removed (R23).
* No mutation here Settings::disableTwoFactor() still runs its own
* requireConfirmedPassword() check, unchanged.
*/
class ConfirmDisableTwoFactor extends ModalComponent
{
public function confirm(): void
{
$this->dispatch('twofa-disable-confirmed');
$this->closeModal();
}
public function render()
{
return view('livewire.confirm-disable-two-factor');
}
}

View File

@ -1,25 +0,0 @@
<?php
namespace App\Livewire;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a customer signs every other browser out (R23).
*
* Mutates nothing. Sessions::endOthers() still does the work, with its own
* identity resolution the check stays at the one place that already had it.
*/
class ConfirmEndOtherSessions extends ModalComponent
{
public function confirm(): void
{
$this->dispatch('sessions-end-others-confirmed');
$this->closeModal();
}
public function render()
{
return view('livewire.confirm-end-other-sessions');
}
}

View File

@ -1,73 +0,0 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Order;
use LivewireUI\Modal\ModalComponent;
/**
* Taking a not-yet-paid purchase back out of the cart.
*
* Scoped to the signed-in customer's own orders: modals are reachable without
* the page's own guards, so the ownership check belongs here, not in the view
* that happens to link to it.
*/
class ConfirmRemoveOrder extends ModalComponent
{
use ResolvesCustomer;
public string $uuid;
public string $label = '';
public int $amountCents = 0;
public function mount(string $uuid): void
{
$order = $this->order($uuid);
abort_if($order === null, 404);
$this->uuid = $uuid;
$this->label = $order->label();
$this->amountCents = $order->amount_cents;
}
public function remove(): void
{
$order = $this->order($this->uuid);
// Gone or already paid for: say so instead of pretending it worked.
if ($order === null) {
$this->dispatch('notify', message: __('billing.cart.gone'));
$this->closeModal();
return;
}
$order->delete();
$this->dispatch('order-removed');
$this->closeModal();
}
/** @return Order|null the customer's own, still-pending order */
private function order(string $uuid): ?Order
{
$customer = $this->customer();
if ($customer === null) {
return null;
}
return Order::query()
->where('customer_id', $customer->id)
->where('uuid', $uuid)
->where('status', 'pending')
->first();
}
public function render()
{
return view('livewire.confirm-remove-order');
}
}

View File

@ -1,43 +0,0 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a seat's access ends (R23). Mirrors EditSeat: a modal
* is reachable without the page's route middleware, so the seat is resolved
* fresh from the signed-in customer rather than trusted from the caller.
*/
class ConfirmRevokeSeat extends ModalComponent
{
use ResolvesCustomer;
public string $uuid;
public string $name = '';
public function mount(string $uuid): void
{
$seat = $this->customer()?->seats()->where('uuid', $uuid)->first();
// Mirrors the button's own visibility: the row never offers "revoke"
// for the owner seat, so a stale or forged uuid gets 404 here too.
abort_if($seat === null || $seat->role === 'owner', 404);
$this->uuid = $uuid;
$this->name = (string) ($seat->name ?: $seat->email);
}
public function confirm(): void
{
$this->dispatch('seat-revoke-confirmed', uuid: $this->uuid);
$this->closeModal();
}
public function render()
{
return view('livewire.confirm-revoke-seat');
}
}

View File

@ -1,59 +0,0 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\BuildsRunSteps;
use App\Models\Customer;
use App\Models\Order;
use App\Models\ProvisioningRun;
use Livewire\Component;
/**
* Embedded live provisioning progress for the logged-in customer's order.
* Polls only itself so the dashboard's charts don't churn. Renders nothing once
* provisioning has completed.
*/
class CustomerProvisioning extends Component
{
use BuildsRunSteps;
private function currentRun(): ?ProvisioningRun
{
$user = auth()->user();
if ($user === null) {
return null;
}
$customer = Customer::query()->where('email', $user->email)->first();
if ($customer === null) {
return null;
}
return ProvisioningRun::query()
->where('subject_type', Order::class)
->whereIn('subject_id', $customer->orders()->select('id'))
->latest('id')
->first();
}
public function render()
{
$run = $this->currentRun();
// Show the card while provisioning is in flight OR terminally failed; but
// only POLL while it can still change (terminal failed must not poll forever).
$show = $run !== null && $run->status !== ProvisioningRun::STATUS_COMPLETED;
$polling = $run !== null && in_array($run->status, [
ProvisioningRun::STATUS_PENDING,
ProvisioningRun::STATUS_RUNNING,
ProvisioningRun::STATUS_WAITING,
], true);
return view('livewire.customer-provisioning', [
'active' => $show,
'polling' => $polling,
'failed' => $run?->status === ProvisioningRun::STATUS_FAILED,
'steps' => $show ? $this->buildRunSteps($run) : [],
]);
}
}

View File

@ -1,373 +0,0 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use App\Models\Datacenter;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\MaintenanceWindow;
use App\Models\Seat;
use App\Models\Subscription;
use App\Services\Traffic\TrafficMeter;
use Illuminate\Support\Carbon;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The customer's Betriebsblatt: what they have, and the evidence that it is
* being looked after.
*
* Every figure on this page comes from a record. Where something is not
* measured yet storage consumption, restore tests the page says nothing
* rather than showing a plausible number: this is the sheet a customer forwards
* to their auditor, and one invented line on it is worse than a short register.
*/
#[Layout('layouts.portal-app')]
class Dashboard extends Component
{
use ResolvesCustomer;
public function render()
{
$customer = $this->customer();
// The instance that is actually in service. A cancelled one still has
// rows, and would otherwise keep filling this page after it is gone.
$instance = $customer?->instances()
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->latest('id')
->first();
$contract = $instance?->subscription;
$maintenance = MaintenanceWindow::forInstance($instance)->first();
$domain = $this->domain($instance);
return view('livewire.dashboard', [
'customer' => $customer,
'instance' => $instance,
'contract' => $contract,
'domain' => $domain,
'location' => $this->location($instance),
'seats' => $this->seats($customer, $contract),
'traffic' => $instance !== null ? TrafficMeter::for($instance) : null,
// The measured series behind the template's ring and trend. Null
// where nothing has been sampled yet — a new instance has no
// fortnight, and drawing one at zero would tell its owner their
// data had vanished.
'disk' => $instance !== null ? $this->disk($instance) : null,
'availability' => $instance !== null ? InstanceMetric::availability($instance) : null,
'availabilityTrend' => $instance !== null ? InstanceMetric::availabilitySeries($instance) : [],
'seatBreakdown' => $this->seatBreakdown($customer),
'trend' => $instance !== null
? InstanceMetric::series($instance)->map(fn (InstanceMetric $m) => $m->rx_bytes + $m->tx_bytes)->all()
: [],
'proofs' => $this->proofs($instance, $maintenance),
'openTasks' => $instance?->onboardingTasks()->where('done', false)->count() ?? 0,
// What the plan costs is true whether or not another invoice is
// coming — a customer who has given notice still pays until the
// term ends and still needs the figure on their master record.
'planPrice' => $contract === null ? null : [
'cents' => (int) $contract->price_cents,
'currency' => (string) ($contract->currency ?: 'EUR'),
'term' => (string) $contract->term,
],
'nextInvoice' => $this->nextInvoice($contract, $instance),
// The initial admin password, if it is still waiting to be
// acknowledged. Read straight off $instance (already scoped to
// THIS customer above) rather than a fresh unscoped lookup, and
// never stored as a public property — see acknowledgeCredentials().
'credentials' => $this->credentials($instance, $domain),
'asOf' => Carbon::now(),
]);
}
/**
* Deletes the instance's stored admin password and stamps the
* acknowledgement the customer has confirmed they noted it down.
*
* Re-resolves both the customer AND the instance from the authenticated
* session rather than trusting $uuid alone: this method is reachable by
* anyone who can post to /livewire/update, not only through this card's
* button, so ownership is checked here again rather than relying on the
* card simply not being shown to anyone else.
*/
public function acknowledgeCredentials(string $uuid): void
{
$customer = $this->requireCustomer();
if ($customer === null) {
return;
}
$instance = $customer->instances()->where('uuid', $uuid)->whereNotNull('admin_password')->first();
if ($instance === null) {
return;
}
$instance->update([
'admin_password' => null,
'credentials_acknowledged_at' => now(),
]);
$this->dispatch('notify', message: __('dashboard.credentials.acknowledged'));
}
/**
* @return array{uuid: string, url: string, user: string, password: string}|null
*/
private function credentials(?Instance $instance, ?string $domain): ?array
{
if ($instance === null || $instance->admin_password === null) {
return null;
}
return [
'uuid' => $instance->uuid,
'url' => 'https://'.($domain ?: $instance->subdomain),
'user' => (string) $instance->nc_admin_ref,
'password' => $instance->admin_password,
];
}
/**
* Who holds the seats, so the card can say something true underneath the
* figure rather than repeating it.
*
* @return array<string, int> role => count, roles with nobody omitted
*/
private function seatBreakdown(?Customer $customer): array
{
if ($customer === null) {
return [];
}
return Seat::query()
->where('customer_id', $customer->id)
->whereIn('status', ['active', 'invited'])
->selectRaw('role, COUNT(*) as n')
->groupBy('role')
->pluck('n', 'role')
->filter()
->all();
}
/**
* How full the instance is, as measured not as sold.
*
* Null until the sampler has managed a reading. The card then states the
* contractual allowance, which is true, rather than a ring at a level
* nobody measured.
*
* @return array{used: int, total: int, percent: float, week_delta: int|null}|null
*/
private function disk(Instance $instance): ?array
{
$metric = InstanceMetric::latestDisk($instance);
if ($metric === null || ! $metric->disk_total_bytes) {
return null;
}
// What it grew by this week, which is the line the template carries
// under the ring. Null when there is no reading a week back to compare
// against — an instance three days old has no weekly trend, and
// inventing "+0 GB" would read as "nothing happened".
$weekAgo = InstanceMetric::query()
->where('instance_id', $instance->id)
->whereNotNull('disk_used_bytes')
->where('day', '<=', now()->subDays(7)->toDateString())
->orderByDesc('day')
->first();
return [
'used' => $metric->disk_used_bytes,
'total' => $metric->disk_total_bytes,
'percent' => round($metric->disk_used_bytes / max(1, $metric->disk_total_bytes) * 100, 1),
'week_delta' => $weekAgo !== null ? $metric->disk_used_bytes - $weekAgo->disk_used_bytes : null,
];
}
/** The address the customer actually reaches their cloud at. */
private function domain(?Instance $instance): ?string
{
if ($instance === null) {
return null;
}
return $instance->custom_domain
?: ($instance->subdomain
? $instance->subdomain.'.'.config('provisioning.dns.zone', 'clupilot.com')
: null);
}
/**
* Where the data physically sits.
*
* Named from the datacenter register rather than from a translation string,
* because this is the line a customer copies into a processing record it
* has to be the place their instance really runs on, not the place we
* usually sell.
*
* @return array{name: string, note: null}|null
*/
private function location(?Instance $instance): ?array
{
$code = $instance?->host?->datacenter;
if ($code === null || $code === '') {
return null;
}
$datacenter = Datacenter::query()->where('code', $code)->first();
// The country, not the site. "Falkenstein" is how an operator places an
// instance; a customer's processing record says which jurisdiction the
// data sits in, and naming the building means editing customer-facing
// copy every time the estate grows.
return [
'name' => $datacenter?->location ?: $code,
'note' => null,
];
}
/**
* Seats in use against seats bought.
*
* Invited seats count as used: the licence is committed the moment the
* invitation goes out, and showing them as free is how a customer finds out
* at the worst moment that they cannot add the person in front of them.
*
* @return array{used: int, total: int|null}
*/
private function seats(?Customer $customer, ?Subscription $contract): array
{
return [
'used' => $customer === null ? 0 : Seat::query()
->where('customer_id', $customer->id)
->whereIn('status', ['active', 'invited'])
->count(),
'total' => $contract?->seats,
];
}
/**
* The proof register what was done for this customer, and when.
*
* Only entries backed by a record. A row here is something the customer can
* be asked to evidence, so "we back up nightly" is not one; "the last backup
* completed at 03:12 and reported ok" is.
*
* @return array<int, array{key: string, at: \Illuminate\Support\Carbon|null, state: string, note: string|null}>
*/
private function proofs(?Instance $instance, ?MaintenanceWindow $maintenance): array
{
if ($instance === null) {
return [];
}
$proofs = [];
// A failing job outranks a succeeding one, whatever their dates.
//
// Ordering by last_ok_at alone hides exactly the case this register
// exists for: last night's job failed and so has no success time at
// all, an older row that DID succeed sorts above it, and the sheet
// reports backups as fine. On the page a customer shows their auditor,
// a problem has to beat a date.
$backups = $instance->backups()->get();
$backup = $backups->first(fn ($b) => $b->status !== 'ok')
?? $backups->sortByDesc('last_ok_at')->first();
if ($backup !== null) {
$proofs[] = [
'key' => 'backup',
'at' => $backup->last_ok_at,
// The job's own verdict, not "there is a row, so it worked".
'state' => $backup->status === 'ok' ? 'ok' : 'attention',
'note' => $backup->schedule,
];
}
// A certificate that stopped renewing is the failure a customer meets as
// "my browser says my own cloud is unsafe", so it is stated either way.
$proofs[] = [
'key' => 'certificate',
'at' => null,
'state' => $instance->cert_ok ? 'ok' : 'attention',
'note' => null,
];
if ($maintenance !== null) {
$proofs[] = [
'key' => 'maintenance',
'at' => $maintenance->starts_at,
'state' => 'planned',
'note' => $maintenance->title,
];
}
if ($instance->service_ends_at !== null) {
$proofs[] = [
'key' => 'service_ends',
'at' => $instance->service_ends_at,
'state' => 'attention',
'note' => null,
];
}
// Newest first, but an entry with no time of its own — the certificate —
// stays at the top rather than sinking to the bottom of the register.
usort($proofs, fn (array $a, array $b) => ($b['at']?->timestamp ?? PHP_INT_MAX) <=> ($a['at']?->timestamp ?? PHP_INT_MAX));
return $proofs;
}
/**
* What is owed next, from the contract rather than from a price list.
*
* The plan and the modules are kept apart. A customer with modules booked
* would otherwise read the plan's price as their bill and be short every
* month and putting the combined figure next to the word "Paket" on the
* master record would be just as wrong in the other direction.
*
* Add-ons carry a MONTHLY price (SubscriptionAddon::monthlyCents), while the
* plan's price_cents is the price of a whole term. On a yearly contract the
* modules therefore multiply by twelve to land on the same invoice.
*
* Nothing at all once the customer has given notice. Cancelling leaves the
* subscription `active` until the term runs out that is deliberate, the
* service keeps running but `current_period_end` is then the day service
* ENDS, not the day the next charge falls. Billing a customer, on the sheet
* they check their invoices against, for a renewal that will never happen is
* the worst single line this page could carry. The end date is still stated;
* it is in the proof register as "contract ends".
*
* @return array{plan: int, addons: int, total: int, currency: string, due: \Illuminate\Support\Carbon|null, term: string}|null
*/
private function nextInvoice(?Subscription $contract, ?Instance $instance): ?array
{
if ($contract === null || $contract->status === 'cancelled') {
return null;
}
if ($instance?->cancel_requested_at !== null || $instance?->service_ends_at !== null) {
return null;
}
$months = $contract->isYearly() ? 12 : 1;
$addons = $contract->addons
->whereNull('cancelled_at')
->sum(fn ($addon) => $addon->monthlyCents()) * $months;
return [
'plan' => (int) $contract->price_cents,
'addons' => (int) $addons,
'total' => (int) $contract->price_cents + (int) $addons,
'currency' => (string) ($contract->currency ?: 'EUR'),
'due' => $contract->current_period_end,
'term' => (string) $contract->term,
];
}
}

View File

@ -1,160 +0,0 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ConfirmsPassword;
use App\Models\Mailbox;
use App\Services\Mail\MailPurpose;
use App\Services\Secrets\SecretCipher;
use App\Support\Settings;
use LivewireUI\Modal\ModalComponent;
/**
* Editing a mailbox, in a modal (R20).
*
* A modal is reachable WITHOUT the page's route middleware, so it authorises
* itself and re-reads the record instead of trusting a property the browser
* hydrated. save() re-authorises independently of mount() for the same reason
* Secrets::guard() checks on every action rather than once: a Livewire action
* is reachable by anyone who can post to /livewire/update, and a mount that
* succeeded earlier in the session proves nothing about the request now.
*/
class EditMailbox extends ModalComponent
{
use ConfirmsPassword;
public string $uuid = '';
public string $address = '';
public string $displayName = '';
public string $username = '';
/** Always starts empty: a stored password never travels to the browser. */
public string $password = '';
public bool $noReply = false;
public bool $active = true;
/** Whether this mailbox logs in before it sends — Codex R15#4, P1b. */
public bool $authenticates = true;
protected function confirmationGuard(): string
{
return 'operator';
}
public function mount(string $uuid): void
{
$this->authorize('mail.manage');
$box = Mailbox::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $box->uuid;
$this->address = $box->address;
$this->displayName = (string) $box->display_name;
$this->username = (string) $box->username;
$this->noReply = $box->no_reply;
$this->active = $box->active;
$this->authenticates = $box->authenticates;
}
public function save(): void
{
$this->authorize('mail.manage');
$this->validate([
'address' => ['required', 'email', 'max:255'],
'displayName' => ['nullable', 'string', 'max:255'],
'username' => ['nullable', 'string', 'max:255'],
'password' => ['nullable', 'string', 'max:255'],
]);
$box = Mailbox::query()->where('uuid', $this->uuid)->firstOrFail();
// Deactivating the mailbox "system" currently points at is not just
// this purpose losing its sender: MailboxResolver::active() filters an
// inactive mailbox at BOTH the direct lookup AND the system fallback,
// so every OTHER purpose that has no mailbox of its own falls through
// to nothing too. savePurposes() already refuses to leave system
// empty; this closes the other way to reach the same broken state.
if (! $this->active && $box->key === (string) Settings::get(MailPurpose::settingKey(MailPurpose::SYSTEM))) {
$this->addError('active', __('mail_settings.cannot_deactivate_system'));
return;
}
// Setting a new SMTP password is one of the two actions the
// mail.manage split still leaves able to intercept mail outright (the
// other is Admin\Mail::saveServer()) — gated the same second way
// Admin\Integrations' vault entries are: a capability decides who
// may open this modal at all, a recent password decides whether
// THIS session may point the
// outgoing account at new credentials.
if ($this->password !== '') {
abort_unless($this->passwordRecentlyConfirmed(), 403);
// A password can only be STORED where SECRETS_KEY exists to
// encrypt it under — the same condition the page's own banner
// already names. Checked here rather than left for
// Mailbox::setPasswordAttribute() to throw through: an uncaught
// RuntimeException would render Laravel's debug page, whose
// request-payload inspector can show the very password just
// typed. The page must say so, not crash on it.
if (! app(SecretCipher::class)->isUsable()) {
$this->addError('password', __('mail_settings.no_key'));
return;
}
}
$oldAddress = $box->address;
$oldUsername = $box->username;
$oldAuthenticates = $box->authenticates;
$box->address = $this->address;
$box->display_name = $this->displayName ?: null;
$box->username = $this->username ?: null;
$box->no_reply = $this->noReply;
$box->active = $this->active;
$box->authenticates = $this->authenticates;
// Either one changes the identity smtpUsername() authenticates as:
// username directly, or address as username's fallback when none is
// set (including username being CLEARED back to that fallback).
// authenticates itself is the fourth: whether that identity gets
// sent at all. Leaving last_verified_at standing after any of the
// three would keep showing a successful verification for a
// connection nothing has actually tested since. Mailbox::
// invalidateVerification() is the same clear Admin\Mail::saveServer()
// uses for the server card, in its single-row shape — see that
// method's own comment for why the two do NOT also share the "did
// this actually change" comparison above this block.
if ($box->address !== $oldAddress || $box->username !== $oldUsername || $box->authenticates !== $oldAuthenticates) {
$box->invalidateVerification();
}
// Empty means "leave it alone", not "delete it" — otherwise every edit
// of an address would silently drop the password.
if ($this->password !== '') {
$box->password = $this->password;
$box->invalidateVerification();
}
$box->save();
$this->password = '';
$this->dispatch('notify', message: __('mail_settings.saved'));
$this->dispatch('mailbox-saved');
$this->closeModal();
}
public function render()
{
return view('livewire.edit-mailbox', [
'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
]);
}
}

View File

@ -1,108 +0,0 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Seat;
use LivewireUI\Modal\ModalComponent;
/**
* Edit one seat, in a modal.
*
* Editing used to happen in the row itself. It worked and it looked wrong: the
* row grew, the columns beside it jumped, and a table half in edit mode reads
* as a rendering fault rather than as a form. Anything with fields of its own
* gets a modal see R20.
*
* A modal is reachable without passing the page's route middleware, so the
* customer is resolved here rather than trusted from the caller: the uuid comes
* from the browser and must never reach across customers.
*/
class EditSeat extends ModalComponent
{
use ResolvesCustomer;
public string $uuid = '';
public string $name = '';
public string $email = '';
/** Owners cannot be suspended or removed; everyone can be renamed. */
public bool $isOwner = false;
/**
* Whether the address may still be corrected.
*
* Only while the invitation is in flight. Once someone has accepted, the
* address IS the person: editing it would hand one employee's access to
* another with nobody told a transfer of access wearing the clothes of a
* rename.
*/
public bool $addressEditable = false;
public function mount(string $uuid): void
{
$seat = $this->seat($uuid);
abort_if($seat === null, 404);
$this->uuid = $uuid;
$this->name = (string) $seat->name;
$this->email = (string) $seat->email;
$this->isOwner = $seat->role === 'owner';
$this->addressEditable = $seat->status === 'invited';
}
public function save()
{
$customer = $this->requireCustomer();
$seat = $this->seat($this->uuid);
if ($customer === null || $seat === null) {
return $this->closeModal();
}
$rules = ['name' => 'nullable|string|max:255'];
// Re-read from the record, never from the hydrated property: a forged
// addressEditable would otherwise open the address of an accepted seat.
if ($seat->status === 'invited') {
$rules['email'] = 'required|email|max:255';
}
$data = $this->validate($rules);
$changes = ['name' => trim($data['name'] ?? '') ?: null];
if ($seat->status === 'invited') {
$address = trim($data['email']);
if ($address !== $seat->email) {
if ($customer->seats()->where('email', $address)->whereKeyNot($seat->id)->exists()) {
$this->addError('email', __('users.duplicate'));
return null;
}
$changes['email'] = $address;
}
}
$seat->update($changes);
$this->dispatch('notify', message: __('users.saved'));
return $this->redirectRoute('users', navigate: true);
}
private function seat(string $uuid): ?Seat
{
return $this->customer()?->seats()->where('uuid', $uuid)->first();
}
public function render()
{
return view('livewire.edit-seat');
}
}

View File

@ -1,58 +0,0 @@
<?php
namespace App\Livewire;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Invoices extends Component
{
public function render()
{
$locale = app()->getLocale();
$eur = fn (float $v) => Number::currency($v, in: 'EUR', locale: $locale);
$date = fn (string $iso) => Carbon::parse($iso)->local()->locale($locale)->isoFormat('LL');
$months = collect(['2026-03-01', '2026-04-01', '2026-05-01', '2026-06-01', '2026-07-01'])
->map(fn ($m) => Carbon::parse($m)->local()->locale($locale)->isoFormat('MMM'))->all();
$rows = [
['no' => 'CP-2026-0007', 'date' => $date('2026-07-01'), 'amount' => $eur(198), 'status' => 'paid'],
['no' => 'CP-2026-0006', 'date' => $date('2026-06-01'), 'amount' => $eur(198), 'status' => 'paid'],
['no' => 'CP-2026-0005', 'date' => $date('2026-05-01'), 'amount' => $eur(198), 'status' => 'paid'],
['no' => 'CP-2026-0004', 'date' => $date('2026-04-01'), 'amount' => $eur(179), 'status' => 'paid'],
['no' => 'CP-2026-0003', 'date' => $date('2026-03-15'), 'amount' => $eur(179), 'status' => 'paid'],
];
return view('livewire.invoices', [
'rows' => $rows,
'nextCharge' => $date('2026-08-01'),
'nextAmount' => $eur(198),
'spendChart' => [
'type' => 'line',
'data' => [
'labels' => $months,
'datasets' => [[
'label' => 'EUR',
'data' => [179, 179, 198, 198, 198],
'borderColor' => 'token:accent',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
'borderWidth' => 2,
]],
],
'options' => [
'scales' => [
'x' => ['grid' => ['display' => false]],
'y' => ['beginAtZero' => true, 'grid' => ['color' => 'token:border']],
],
'plugins' => ['legend' => ['display' => false]],
],
],
]);
}
}

View File

@ -1,72 +0,0 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\SupportRequest;
use Illuminate\Validation\Rule;
use LivewireUI\Modal\ModalComponent;
/**
* Raise a support request.
*
* The customer is asked for three things and nothing else: what it is about,
* a subject, and the question. Everything an operator would otherwise have to
* ask for which customer, which instance, which plan the system already
* knows and attaches itself. Making somebody describe their own server back to
* the people who built it is the part of support that annoys people most.
*/
class NewSupportRequest extends ModalComponent
{
use ResolvesCustomer;
public string $category = 'technical';
public string $subject = '';
public string $body = '';
public function save()
{
$customer = $this->requireCustomer();
if ($customer === null) {
return $this->closeModal();
}
$data = $this->validate([
'category' => ['required', Rule::in(SupportRequest::CATEGORIES)],
'subject' => 'required|string|min:3|max:150',
'body' => 'required|string|min:10|max:5000',
]);
$instance = $customer->instances()
->whereIn('status', ['active', 'cancellation_scheduled'])
->latest('id')
->first()
?? $customer->instances()->latest('id')->first();
SupportRequest::create([
'customer_id' => $customer->id,
'instance_id' => $instance?->id,
'subject' => $data['subject'],
'category' => $data['category'],
'body' => $data['body'],
'status' => 'open',
// The person, not the account: on a shared login the account name
// says nothing about who is actually asking.
'reported_by' => auth()->user()?->name ?: auth()->user()?->email,
]);
$this->dispatch('notify', message: __('support.sent'));
return $this->redirectRoute('support', navigate: true);
}
public function render()
{
return view('livewire.new-support-request', [
'categories' => SupportRequest::CATEGORIES,
]);
}
}

Some files were not shown because too many files have changed in this diff Show More