Compare commits
No commits in common. "feat/granted-plans" and "feat/bootstrap" have entirely different histories.
feat/grant
...
feat/boots
187
.env.example
187
.env.example
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
@ -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
269
CLAUDE.md
|
|
@ -1,269 +0,0 @@
|
|||
# CluPilot — verbindliche Regeln (Repo-Teil)
|
||||
|
||||
Die **Vollfassung der Regeln R1–R17 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.
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,138 +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.
|
||||
*
|
||||
* `$overrides` exists for GrantAddon: a granted module is booked through this
|
||||
* same action, with its price replaced by what the customer actually pays and
|
||||
* its provenance stamped on the row. Empty for every ordinary booking.
|
||||
*/
|
||||
class BookAddon
|
||||
{
|
||||
public function __construct(private RecordCommercialEvent $record) {}
|
||||
|
||||
/** @param array<string, mixed> $overrides */
|
||||
public function __invoke(Subscription $subscription, string $addonKey, int $quantity = 1, ?Order $order = null, array $overrides = []): 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, $overrides);
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $overrides */
|
||||
private function book(Subscription $subscription, string $addonKey, int $quantity, ?Order $order, int $price, array $overrides = []): 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, $overrides) {
|
||||
// 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(array_merge([
|
||||
'subscription_id' => $subscription->id,
|
||||
'order_id' => $order?->id,
|
||||
'addon_key' => $addonKey,
|
||||
'price_cents' => $price,
|
||||
'currency' => Subscription::catalogueCurrency(),
|
||||
'quantity' => $quantity,
|
||||
'booked_at' => now(),
|
||||
], $overrides));
|
||||
|
||||
($this->record)(
|
||||
event: SubscriptionRecord::EVENT_ADDON_BOOKED,
|
||||
subscription: $subscription,
|
||||
netCents: $addon->monthlyCents(),
|
||||
extra: ['addon' => ['key' => $addonKey, 'quantity' => $quantity, 'price_cents' => $addon->price_cents]],
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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'];
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\Operator;
|
||||
use App\Models\Order;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\SubscriptionAddon;
|
||||
use App\Services\Billing\AddonCatalogue;
|
||||
use Illuminate\Support\Carbon;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Gives a customer a module without a payment — "ein Plugin schenken".
|
||||
*
|
||||
* Books through the same BookAddon a paid order uses, on a synthetic Order
|
||||
* (no Stripe id, price set to what the customer actually pays) so the module
|
||||
* ends up frozen on `subscription_addons` exactly like a real booking would.
|
||||
*/
|
||||
class GrantAddon
|
||||
{
|
||||
public function __construct(private BookAddon $bookAddon) {}
|
||||
|
||||
public function __invoke(
|
||||
Subscription $subscription,
|
||||
Operator $grantedBy,
|
||||
string $addonKey,
|
||||
int $priceCents,
|
||||
int $quantity = 1,
|
||||
?string $note = null,
|
||||
?Carbon $until = null,
|
||||
): SubscriptionAddon {
|
||||
$cataloguePrice = app(AddonCatalogue::class)->priceCents($addonKey);
|
||||
|
||||
if ($cataloguePrice === null) {
|
||||
throw new RuntimeException("Unknown add-on: {$addonKey}");
|
||||
}
|
||||
|
||||
if ($priceCents < 0 || $priceCents > $cataloguePrice) {
|
||||
throw new RuntimeException('A grant cannot charge more than the catalogue price.');
|
||||
}
|
||||
|
||||
// Vestigial for a module — nothing is provisioned at a datacenter for
|
||||
// it — but the column is not nullable, and the contract's own order
|
||||
// already carries the right one.
|
||||
$datacenter = $subscription->order?->datacenter ?? 'fsn';
|
||||
|
||||
$order = Order::create([
|
||||
'customer_id' => $subscription->customer_id,
|
||||
'plan' => $subscription->plan,
|
||||
'type' => 'addon',
|
||||
'addon_key' => $addonKey,
|
||||
'amount_cents' => $priceCents,
|
||||
'currency' => Subscription::catalogueCurrency(),
|
||||
'datacenter' => $datacenter,
|
||||
'stripe_event_id' => null,
|
||||
'stripe_subscription_id' => null,
|
||||
'status' => 'paid',
|
||||
]);
|
||||
|
||||
return ($this->bookAddon)($subscription, $addonKey, $quantity, $order, [
|
||||
'price_cents' => $priceCents,
|
||||
'granted_by' => $grantedBy->id,
|
||||
'granted_at' => now(),
|
||||
'grant_note' => $note,
|
||||
'granted_until' => $until,
|
||||
'catalogue_price_cents' => $cataloguePrice,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Operator;
|
||||
use App\Models\Order;
|
||||
use App\Models\ProvisioningRun;
|
||||
use App\Models\Subscription;
|
||||
use App\Provisioning\Jobs\AdvanceRunJob;
|
||||
use App\Services\Billing\PlanCatalogue;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Gives a customer a package without a payment.
|
||||
*
|
||||
* A grant is an order without money, not a second code path: this creates the
|
||||
* exact Order → ProvisioningRun → Subscription rows a Stripe purchase does —
|
||||
* same pipeline, same OpenSubscription — with `stripe_subscription_id` left
|
||||
* null and `price_cents` set to whatever the customer actually pays (0 for a
|
||||
* full gift, less than the catalogue price for a discount). Provisioning,
|
||||
* DowngradeCheck, seats, quotas, cancellation: all of it reads an ordinary
|
||||
* Subscription row afterwards and never learns this one was free.
|
||||
*/
|
||||
class GrantSubscription
|
||||
{
|
||||
public function __construct(private OpenSubscription $openSubscription) {}
|
||||
|
||||
/**
|
||||
* @param array{quota_gb?:int,seats?:int,traffic_gb?:int} $bonus Beyond what the plan tier already includes.
|
||||
*/
|
||||
public function __invoke(
|
||||
Customer $customer,
|
||||
Operator $grantedBy,
|
||||
string $plan,
|
||||
string $term,
|
||||
string $datacenter,
|
||||
int $priceCents,
|
||||
array $bonus = [],
|
||||
?string $note = null,
|
||||
?Carbon $until = null,
|
||||
): Subscription {
|
||||
if (! Subscription::knowsPlan($plan)) {
|
||||
throw new RuntimeException("Plan '{$plan}' is not sellable, so it cannot be granted either.");
|
||||
}
|
||||
|
||||
$version = app(PlanCatalogue::class)->currentVersion($plan);
|
||||
$cataloguePrice = $version->priceFor($term);
|
||||
|
||||
if ($cataloguePrice === null) {
|
||||
throw new RuntimeException("Plan '{$plan}' is not sold on a {$term} term.");
|
||||
}
|
||||
|
||||
// A "grant" that charges more than the plan actually costs is not a
|
||||
// grant — that path already exists, and it goes through Stripe.
|
||||
if ($priceCents < 0 || $priceCents > $cataloguePrice->amount_cents) {
|
||||
throw new RuntimeException('A grant cannot charge more than the catalogue price.');
|
||||
}
|
||||
|
||||
[$run, $subscription] = DB::transaction(function () use (
|
||||
$customer, $grantedBy, $plan, $term, $datacenter, $priceCents, $bonus, $note, $until, $cataloguePrice,
|
||||
) {
|
||||
$order = Order::create([
|
||||
'customer_id' => $customer->id,
|
||||
'plan' => $plan,
|
||||
'type' => 'new',
|
||||
'amount_cents' => $priceCents,
|
||||
'currency' => Subscription::catalogueCurrency(),
|
||||
'datacenter' => $datacenter,
|
||||
// Never Stripe's: this purchase never went through Stripe at
|
||||
// all, which is the whole point, and is also what tells
|
||||
// IssueInvoice and the revenue figures this row apart from an
|
||||
// ordinary (possibly also zero-priced) checkout.
|
||||
'stripe_event_id' => null,
|
||||
'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,
|
||||
'context' => ['branding' => $customer->brandingResolved()],
|
||||
]);
|
||||
|
||||
$overrides = array_filter([
|
||||
'price_cents' => $priceCents,
|
||||
'quota_gb' => $bonus['quota_gb'] ?? null,
|
||||
'seats' => $bonus['seats'] ?? null,
|
||||
'traffic_gb' => $bonus['traffic_gb'] ?? null,
|
||||
], fn ($value) => $value !== null);
|
||||
|
||||
$overrides += [
|
||||
'granted_by' => $grantedBy->id,
|
||||
'granted_at' => now(),
|
||||
'grant_note' => $note,
|
||||
'granted_until' => $until,
|
||||
'catalogue_price_cents' => $cataloguePrice->amount_cents,
|
||||
];
|
||||
|
||||
$subscription = ($this->openSubscription)($order, $term, $overrides);
|
||||
|
||||
return [$run, $subscription];
|
||||
});
|
||||
|
||||
// After the commit, never inside it — a worker can pick the run up
|
||||
// before the transaction lands (see StartCustomerProvisioning).
|
||||
AdvanceRunJob::dispatch($run->uuid);
|
||||
|
||||
return $subscription;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,108 +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.
|
||||
*
|
||||
* `$overrides` exists for exactly one caller, GrantSubscription: a grant opens
|
||||
* a contract through this same action rather than a second one, but needs to
|
||||
* set what the customer actually pays (0, or less than the catalogue price)
|
||||
* and stamp who granted it. Passed straight through to Subscription::create()
|
||||
* on top of the catalogue snapshot — empty for every ordinary purchase, which
|
||||
* is the whole reason this stays a parameter and not a new method.
|
||||
*/
|
||||
class OpenSubscription
|
||||
{
|
||||
public function __construct(private RecordCommercialEvent $record) {}
|
||||
|
||||
/** @param array<string, mixed> $overrides */
|
||||
public function __invoke(Order $order, string $term = Subscription::TERM_MONTHLY, array $overrides = []): 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, $overrides));
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $overrides */
|
||||
private function open(Order $order, string $term, Carbon $start, array $overrides = []): 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',
|
||||
],
|
||||
// Last, so a grant's price and provenance win over the catalogue
|
||||
// snapshot above rather than the other way round.
|
||||
$overrides,
|
||||
));
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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).");
|
||||
}
|
||||
}
|
||||
|
|
@ -2,9 +2,7 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
//
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,116 +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) : '—',
|
||||
// A gift or a discount, not a sale — flagged here rather than
|
||||
// inferred from the price, which a genuinely cheap plan could
|
||||
// also show.
|
||||
'granted' => (bool) $contract?->isGranted(),
|
||||
'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]]],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,293 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Actions\GrantAddon;
|
||||
use App\Actions\GrantSubscription;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Datacenter;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\SubscriptionAddon;
|
||||
use App\Services\Billing\AddonCatalogue;
|
||||
use App\Services\Billing\PlanCatalogue;
|
||||
use App\Support\Money;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Validation\Rule;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Gives a customer a package, a module, or a discount — without a payment.
|
||||
*
|
||||
* Reuses GrantSubscription / GrantAddon for the actual mutation: this
|
||||
* component is only the form. What it grants goes through the exact Order →
|
||||
* Subscription/SubscriptionAddon rows a Stripe purchase would, so everything
|
||||
* downstream (provisioning, quotas, cancellation, revenue) reads it the same
|
||||
* way as a sale.
|
||||
*/
|
||||
class GrantPlan extends ModalComponent
|
||||
{
|
||||
public string $customerUuid = '';
|
||||
|
||||
public string $customerName = '';
|
||||
|
||||
/** Which form is on screen: a whole package, or a single module. */
|
||||
public string $kind = 'package';
|
||||
|
||||
public bool $hasSubscription = false;
|
||||
|
||||
// Package fields.
|
||||
public string $plan = '';
|
||||
|
||||
public string $term = Subscription::TERM_MONTHLY;
|
||||
|
||||
public string $datacenter = '';
|
||||
|
||||
public string $bonusQuotaGb = '';
|
||||
|
||||
public string $bonusSeats = '';
|
||||
|
||||
public string $bonusTrafficGb = '';
|
||||
|
||||
// Add-on fields.
|
||||
public ?int $subscriptionId = null;
|
||||
|
||||
public string $addonKey = '';
|
||||
|
||||
public int $quantity = 1;
|
||||
|
||||
// Shared.
|
||||
public string $priceEuros = '0';
|
||||
|
||||
public string $note = '';
|
||||
|
||||
/** A plain date — "until when", not "until what time". */
|
||||
public string $until = '';
|
||||
|
||||
public function mount(string $uuid): void
|
||||
{
|
||||
$this->authorize('customers.grant_plan'); // modals are reachable without the route middleware
|
||||
$customer = Customer::query()->where('uuid', $uuid)->firstOrFail();
|
||||
|
||||
$this->customerUuid = $uuid;
|
||||
$this->customerName = (string) $customer->name;
|
||||
$this->datacenter = (string) (Datacenter::query()->active()->orderBy('code')->value('code') ?? '');
|
||||
|
||||
$subscription = Subscription::query()
|
||||
->where('customer_id', $customer->id)
|
||||
->where('status', 'active')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$this->hasSubscription = $subscription !== null;
|
||||
$this->subscriptionId = $subscription?->id;
|
||||
// A customer with nothing yet can only be given a package — there is
|
||||
// no contract to hang a module off.
|
||||
$this->kind = $this->hasSubscription ? 'addon' : 'package';
|
||||
}
|
||||
|
||||
/** Today's price for whatever is picked, so a discount reads as one. */
|
||||
public function cataloguePriceCents(): ?int
|
||||
{
|
||||
try {
|
||||
if ($this->kind === 'package' && $this->plan !== '') {
|
||||
return app(PlanCatalogue::class)->currentVersion($this->plan)->priceFor($this->term)?->amount_cents;
|
||||
}
|
||||
|
||||
if ($this->kind === 'addon' && $this->addonKey !== '') {
|
||||
return app(AddonCatalogue::class)->priceCents($this->addonKey);
|
||||
}
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function grant(): void
|
||||
{
|
||||
$this->authorize('customers.grant_plan');
|
||||
|
||||
$operator = auth('operator')->user();
|
||||
abort_if($operator === null, 403);
|
||||
|
||||
$customer = Customer::query()->where('uuid', $this->customerUuid)->firstOrFail();
|
||||
|
||||
if ($this->kind === 'package') {
|
||||
$this->grantPackage($customer, $operator);
|
||||
} else {
|
||||
$this->grantAddon($customer, $operator);
|
||||
}
|
||||
}
|
||||
|
||||
private function grantPackage(Customer $customer, $operator): void
|
||||
{
|
||||
$priceRule = function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if (Money::toCents((string) $value) === null) {
|
||||
$fail(__('admin.grant.price_invalid'));
|
||||
}
|
||||
};
|
||||
|
||||
$data = $this->validate([
|
||||
'plan' => ['required', Rule::in(array_keys(app(PlanCatalogue::class)->sellable()))],
|
||||
'term' => ['required', Rule::in([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY])],
|
||||
'datacenter' => ['required', Rule::exists('datacenters', 'code')->where('active', true)],
|
||||
'priceEuros' => ['required', 'string', $priceRule],
|
||||
'bonusQuotaGb' => 'nullable|integer|min:0|max:1000000',
|
||||
'bonusSeats' => 'nullable|integer|min:0|max:100000',
|
||||
'bonusTrafficGb' => 'nullable|integer|min:0|max:10000000',
|
||||
'note' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
try {
|
||||
app(GrantSubscription::class)(
|
||||
customer: $customer,
|
||||
grantedBy: $operator,
|
||||
plan: $data['plan'],
|
||||
term: $data['term'],
|
||||
datacenter: $data['datacenter'],
|
||||
priceCents: Money::toCents($data['priceEuros']),
|
||||
bonus: array_filter([
|
||||
'quota_gb' => $data['bonusQuotaGb'] !== null && $data['bonusQuotaGb'] !== '' ? (int) $data['bonusQuotaGb'] : null,
|
||||
'seats' => $data['bonusSeats'] !== null && $data['bonusSeats'] !== '' ? (int) $data['bonusSeats'] : null,
|
||||
'traffic_gb' => $data['bonusTrafficGb'] !== null && $data['bonusTrafficGb'] !== '' ? (int) $data['bonusTrafficGb'] : null,
|
||||
], fn ($value) => $value !== null),
|
||||
note: $data['note'] !== null && $data['note'] !== '' ? $data['note'] : null,
|
||||
until: $this->parseUntil($this->until),
|
||||
);
|
||||
} catch (RuntimeException $e) {
|
||||
$this->addError('priceEuros', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('notify', message: __('admin.grant.granted'));
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
private function grantAddon(Customer $customer, $operator): void
|
||||
{
|
||||
$priceRule = function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if (Money::toCents((string) $value) === null) {
|
||||
$fail(__('admin.grant.price_invalid'));
|
||||
}
|
||||
};
|
||||
|
||||
$data = $this->validate([
|
||||
'addonKey' => ['required', Rule::in(array_keys((array) config('provisioning.addons')))],
|
||||
'quantity' => 'required|integer|min:1|max:100',
|
||||
'priceEuros' => ['required', 'string', $priceRule],
|
||||
'note' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
// Re-resolved and re-checked, never trusted from the hydrated
|
||||
// property: a forged request must not be able to book a module onto
|
||||
// someone else's contract.
|
||||
$subscription = Subscription::query()
|
||||
->where('customer_id', $customer->id)
|
||||
->whereKey($this->subscriptionId)
|
||||
->first();
|
||||
|
||||
if ($subscription === null) {
|
||||
$this->addError('addonKey', __('admin.grant.no_subscription'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
app(GrantAddon::class)(
|
||||
subscription: $subscription,
|
||||
grantedBy: $operator,
|
||||
addonKey: $data['addonKey'],
|
||||
priceCents: Money::toCents($data['priceEuros']),
|
||||
quantity: $data['quantity'],
|
||||
note: $data['note'] !== null && $data['note'] !== '' ? $data['note'] : null,
|
||||
until: $this->parseUntil($this->until),
|
||||
);
|
||||
} catch (RuntimeException $e) {
|
||||
$this->addError('priceEuros', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('notify', message: __('admin.grant.granted'));
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* A date, read in the operator's own zone — the same pairing LocalTime
|
||||
* keeps for a datetime-local field, minus the time of day this form does
|
||||
* not ask for. "Until 15 August" means through the end of that day where
|
||||
* the operator is sitting, not UTC midnight.
|
||||
*/
|
||||
private function parseUntil(string $value): ?Carbon
|
||||
{
|
||||
if (trim($value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($value, config('app.display_timezone'))->endOfDay()->utc();
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
private function existingGrants(int $customerId): array
|
||||
{
|
||||
$fromSubscriptions = Subscription::query()
|
||||
->where('customer_id', $customerId)
|
||||
->whereNotNull('granted_at')
|
||||
->with('grantedBy')
|
||||
->get()
|
||||
->map(fn (Subscription $s) => [
|
||||
'kind' => 'package',
|
||||
'label' => __('billing.plan.'.$s->plan),
|
||||
'price_cents' => $s->price_cents,
|
||||
'catalogue_price_cents' => $s->catalogue_price_cents,
|
||||
'granted_by' => $s->grantedBy?->name ?? '—',
|
||||
'granted_at' => $s->granted_at,
|
||||
'granted_until' => $s->granted_until,
|
||||
'note' => $s->grant_note,
|
||||
]);
|
||||
|
||||
$fromAddons = SubscriptionAddon::query()
|
||||
->whereHas('subscription', fn ($q) => $q->where('customer_id', $customerId))
|
||||
->whereNotNull('granted_at')
|
||||
->with('grantedBy')
|
||||
->get()
|
||||
->map(fn (SubscriptionAddon $a) => [
|
||||
'kind' => 'addon',
|
||||
'label' => __('billing.addon.'.$a->addon_key.'.name'),
|
||||
'price_cents' => $a->price_cents,
|
||||
'catalogue_price_cents' => $a->catalogue_price_cents,
|
||||
'granted_by' => $a->grantedBy?->name ?? '—',
|
||||
'granted_at' => $a->granted_at,
|
||||
'granted_until' => $a->granted_until,
|
||||
'note' => $a->grant_note,
|
||||
]);
|
||||
|
||||
return $fromSubscriptions->concat($fromAddons)
|
||||
->sortByDesc('granted_at')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
// Read again rather than off the mounted properties: a second grant
|
||||
// can have happened while this modal sat open (EditDatacenter's
|
||||
// pattern), and the existing-grants list has to show it.
|
||||
$customer = Customer::query()->where('uuid', $this->customerUuid)->firstOrFail();
|
||||
|
||||
return view('livewire.admin.grant-plan', [
|
||||
'plans' => app(PlanCatalogue::class)->sellable(),
|
||||
'addons' => (array) config('provisioning.addons'),
|
||||
'datacenters' => Datacenter::query()->active()->orderBy('code')->pluck('name', 'code'),
|
||||
'cataloguePriceCents' => $this->cataloguePriceCents(),
|
||||
'grants' => $this->existingGrants($customer->id),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,225 +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'),
|
||||
],
|
||||
[
|
||||
// Shown separately rather than folded into the figures
|
||||
// above: a granted contract at 0 must not drag ARPU down
|
||||
// or read as revenue, and the owner measures himself on
|
||||
// this number too.
|
||||
'label' => __('admin.rev.granted'),
|
||||
'value' => (string) $this->grantedCount(),
|
||||
'sub' => __('admin.rev.granted_sub'),
|
||||
],
|
||||
],
|
||||
'planCharts' => $this->planCharts(),
|
||||
'payments' => $this->recentPayments($locale),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recurring revenue per currency, off the PAYING 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.
|
||||
*
|
||||
* Granted contracts are excluded here, not merely zeroed: a contract with
|
||||
* `granted_at` set is a gift or a discount an operator handed out, not a
|
||||
* sale, and counting it — even at 0 — would inflate the contracts count
|
||||
* this average is divided by and understate ARPU for everyone else.
|
||||
*
|
||||
* @return array<string, array{cents:int, customers:array<int,bool>, contracts:int}>
|
||||
*/
|
||||
private function recurringTotals(): array
|
||||
{
|
||||
$totals = [];
|
||||
|
||||
Subscription::query()
|
||||
->where('status', 'active')
|
||||
->whereNull('granted_at')
|
||||
->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. Granted contracts are excluded — see recurringTotals().
|
||||
*
|
||||
* 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')
|
||||
->whereNull('granted_at')
|
||||
->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;
|
||||
}
|
||||
|
||||
/** How many active contracts are gifts or discounts, not sales. */
|
||||
private function grantedCount(): int
|
||||
{
|
||||
return Subscription::query()->where('status', 'active')->whereNotNull('granted_at')->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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() ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,211 +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 App\Services\Billing\TaxTreatment;
|
||||
use App\Services\Traffic\TrafficMeter;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
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'));
|
||||
}
|
||||
|
||||
#[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(),
|
||||
// The portal shows a granted package without a price at all — not
|
||||
// "free", not struck through — so a later conversion to paid does
|
||||
// not read as a negotiation.
|
||||
'granted' => $subscription->isGranted(),
|
||||
'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' => TaxTreatment::for($customer),
|
||||
'trafficMeter' => $instance !== null ? 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,94 +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',
|
||||
// A granted package shows without a price at all — not "free",
|
||||
// not struck through — so a later conversion to paid does not
|
||||
// read as a negotiation.
|
||||
'plan' => $contract?->isGranted()
|
||||
? __('cloud.plan_line_granted', ['plan' => 'CluPilot Cloud '.__('billing.plan.'.$planKey)])
|
||||
: __('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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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) : [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue