Compare commits
No commits in common. "feat/plan-marketing" and "feat/bootstrap" have entirely different histories.
feat/plan-
...
feat/boots
187
.env.example
187
.env.example
|
|
@ -38,7 +38,7 @@ REDIS_HOST=redis
|
||||||
REDIS_PASSWORD=null
|
REDIS_PASSWORD=null
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=redis
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
SESSION_ENCRYPT=false
|
SESSION_ENCRYPT=false
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
|
|
@ -63,198 +63,23 @@ REVERB_SERVER_PORT=8080
|
||||||
# browser-side (baked into built assets): the address a browser can reach
|
# browser-side (baked into built assets): the address a browser can reach
|
||||||
VITE_REVERB_APP_KEY=change-me-app-key
|
VITE_REVERB_APP_KEY=change-me-app-key
|
||||||
VITE_REVERB_HOST=localhost
|
VITE_REVERB_HOST=localhost
|
||||||
VITE_REVERB_PORT=443
|
VITE_REVERB_PORT=8080
|
||||||
VITE_REVERB_SCHEME=https
|
VITE_REVERB_SCHEME=http
|
||||||
VITE_APP_NAME=CluPilot
|
VITE_APP_NAME=CluPilot
|
||||||
|
|
||||||
# Vite HMR host the browser connects to (dev). Set to the reachable VM address
|
# Vite HMR host the browser connects to (dev). Set to the reachable VM address
|
||||||
# when developing against a remote host; localhost for local-only.
|
# when developing against a remote host; localhost for local-only.
|
||||||
VITE_HMR_HOST=localhost
|
VITE_HMR_HOST=localhost
|
||||||
|
|
||||||
# ── Mail ─────────────────────────────────────────────────────────────────
|
# ── Mail (dev → log) ─────────────────────────────────────────────────────
|
||||||
# DEV: `log` writes mails to storage/logs (nothing is sent).
|
|
||||||
# PROD: switch to smtp and fill in the credentials below.
|
|
||||||
MAIL_MAILER=log
|
MAIL_MAILER=log
|
||||||
MAIL_FROM_ADDRESS=hello@clupilot.local
|
MAIL_FROM_ADDRESS=hello@clupilot.local
|
||||||
MAIL_FROM_NAME=CluPilot
|
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) ─────────────────────────────────────
|
# ── docker-compose knobs (host-side) ─────────────────────────────────────
|
||||||
HOST_UID=1000
|
HOST_UID=1000
|
||||||
HOST_GID=1000
|
HOST_GID=1000
|
||||||
# Behind a reverse proxy — which is every production install — these must stay
|
APP_PORT=80
|
||||||
# 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
|
|
||||||
VITE_PORT=5173
|
VITE_PORT=5173
|
||||||
REVERB_HOST_PORT=127.0.0.1:8081
|
REVERB_HOST_PORT=8080
|
||||||
DB_HOST_PORT=3306
|
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
|
||||||
.env.backup
|
.env.backup
|
||||||
.env.production
|
.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
|
.phpactor.json
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
/.codex
|
/.codex
|
||||||
|
|
@ -24,10 +20,6 @@
|
||||||
/public/storage
|
/public/storage
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/pail
|
/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
|
/vendor
|
||||||
_ide_helper.php
|
_ide_helper.php
|
||||||
Homestead.json
|
Homestead.json
|
||||||
|
|
@ -36,7 +28,3 @@ Thumbs.db
|
||||||
|
|
||||||
# Gitea push token (parent home dir; never track)
|
# Gitea push token (parent home dir; never track)
|
||||||
.env.gitea
|
.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,78 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Models\StatusDay;
|
|
||||||
use App\Services\Status\ServiceHealth;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes one sample of every public component into today's row.
|
|
||||||
*
|
|
||||||
* The ninety-day bar on the status page is the one figure a reader checks
|
|
||||||
* against their own memory, so it has to come from something that was actually
|
|
||||||
* recorded at the time. There is no way to reconstruct it afterwards: the
|
|
||||||
* monitoring table holds the LAST verdict per instance, not a history, and by
|
|
||||||
* the time anybody asks about last Tuesday that verdict is about today.
|
|
||||||
*
|
|
||||||
* Runs on the scheduler beside the monitoring sync it depends on. A missed run
|
|
||||||
* costs one sample out of roughly three hundred in a day; a missed DAY leaves
|
|
||||||
* no row at all, and the page draws that as "not recorded" rather than
|
|
||||||
* inventing a colour for it.
|
|
||||||
*/
|
|
||||||
class SampleServiceStatus extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'clupilot:sample-status';
|
|
||||||
|
|
||||||
protected $description = 'Record the current state of every public status component into the daily history';
|
|
||||||
|
|
||||||
public function handle(ServiceHealth $health): int
|
|
||||||
{
|
|
||||||
// The wall clock, not UTC: the bar is labelled with days a reader
|
|
||||||
// recognises, and a sample taken at 01:30 local belongs to the day they
|
|
||||||
// would call it (R19).
|
|
||||||
$day = Carbon::now()->local()->toDateString();
|
|
||||||
|
|
||||||
foreach ($health->components() as $component) {
|
|
||||||
$state = $component['state'];
|
|
||||||
|
|
||||||
// One statement, so two schedulers racing on the same minute cannot
|
|
||||||
// read-modify-write over each other. The counters are increments,
|
|
||||||
// never assignments — a sample is a thing that happened, and a
|
|
||||||
// later run must not be able to undo it.
|
|
||||||
DB::table('status_days')->upsert(
|
|
||||||
[[
|
|
||||||
'day' => $day,
|
|
||||||
'component' => $component['key'],
|
|
||||||
'samples' => 1,
|
|
||||||
'operational' => $state === 'operational' ? 1 : 0,
|
|
||||||
'degraded' => $state === 'degraded' ? 1 : 0,
|
|
||||||
'down' => $state === 'down' ? 1 : 0,
|
|
||||||
'unknown' => $state === 'unknown' ? 1 : 0,
|
|
||||||
'created_at' => now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
]],
|
|
||||||
['day', 'component'],
|
|
||||||
[
|
|
||||||
'samples' => DB::raw('samples + 1'),
|
|
||||||
'operational' => DB::raw('operational + '.($state === 'operational' ? 1 : 0)),
|
|
||||||
'degraded' => DB::raw('degraded + '.($state === 'degraded' ? 1 : 0)),
|
|
||||||
'down' => DB::raw('down + '.($state === 'down' ? 1 : 0)),
|
|
||||||
'unknown' => DB::raw('unknown + '.($state === 'unknown' ? 1 : 0)),
|
|
||||||
'updated_at' => DB::raw('CURRENT_TIMESTAMP'),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Beyond the window the page can draw, the rows are only weight. Kept a
|
|
||||||
// fortnight longer than the ninety days shown so a change of window
|
|
||||||
// does not immediately hit an empty table.
|
|
||||||
StatusDay::query()
|
|
||||||
->where('day', '<', Carbon::now()->local()->subDays(104)->toDateString())
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
|
||||||
|
|
||||||
abstract class Controller
|
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,159 +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
|
|
||||||
{
|
|
||||||
/** 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',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function __invoke(): View
|
|
||||||
{
|
|
||||||
$plans = $this->plans();
|
|
||||||
|
|
||||||
return view('landing', [
|
|
||||||
'plans' => $plans,
|
|
||||||
'featureRows' => $this->featureRows($plans),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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'],
|
|
||||||
// Console-edited, from the family the plan belongs to — a
|
|
||||||
// family that has none yet (created but not written up) gets
|
|
||||||
// an empty string rather than a missing array key, and the
|
|
||||||
// template renders that sensibly instead of an empty gap.
|
|
||||||
'audience' => (string) ($plan['audience'] ?? ''),
|
|
||||||
'note' => (string) ($plan['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' => (bool) ($plan['recommended'] ?? false),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
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,192 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\Incident;
|
|
||||||
use App\Models\MaintenanceWindow;
|
|
||||||
use App\Models\StatusDay;
|
|
||||||
use App\Services\Status\ServiceHealth;
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The public service status page.
|
|
||||||
*
|
|
||||||
* It answers three questions, and they are different questions: what is
|
|
||||||
* happening now, what has happened, and what is about to happen. The page used
|
|
||||||
* to answer only the first, which is the version of a status page that is of no
|
|
||||||
* use the morning after — "Alle Dienste in Betrieb" is true and says nothing to
|
|
||||||
* somebody who was locked out the evening before.
|
|
||||||
*
|
|
||||||
* The measurement itself lives in ServiceHealth, shared with the sampler that
|
|
||||||
* writes the history, so the banner and the bars cannot disagree.
|
|
||||||
*
|
|
||||||
* 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
|
|
||||||
{
|
|
||||||
/** How much history the bars show. Ninety days is the convention. */
|
|
||||||
private const HISTORY_DAYS = 90;
|
|
||||||
|
|
||||||
/** How far back the written record goes on this page. */
|
|
||||||
private const INCIDENT_DAYS = 90;
|
|
||||||
|
|
||||||
public function __invoke(ServiceHealth $health): View
|
|
||||||
{
|
|
||||||
$components = $health->components();
|
|
||||||
|
|
||||||
// An ongoing incident overrides the measurement. Monitoring can be
|
|
||||||
// perfectly happy while something is broken in a way it does not look
|
|
||||||
// at, and in that situation the operator's word beats the probe's.
|
|
||||||
$ongoing = Incident::query()
|
|
||||||
->published()->ongoing()
|
|
||||||
->with('updates')
|
|
||||||
->orderByDesc('started_at')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$components = $this->applyIncidents($components, $ongoing);
|
|
||||||
|
|
||||||
return view('status', [
|
|
||||||
'components' => $components,
|
|
||||||
'overall' => $health->overall($components),
|
|
||||||
'history' => $this->history(),
|
|
||||||
'ongoing' => $ongoing,
|
|
||||||
'past' => $this->past(),
|
|
||||||
'maintenance' => $this->maintenance(),
|
|
||||||
'checkedAt' => Carbon::now(),
|
|
||||||
'historyDays' => self::HISTORY_DAYS,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Let an open incident set the state of the components it names.
|
|
||||||
*
|
|
||||||
* Only downwards. An incident cannot make a component look better than the
|
|
||||||
* probes found it — that direction is how a status page ends up being used
|
|
||||||
* to hide something.
|
|
||||||
*
|
|
||||||
* @param array<int, array<string, mixed>> $components
|
|
||||||
* @param Collection<int, Incident> $ongoing
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private function applyIncidents(array $components, Collection $ongoing): array
|
|
||||||
{
|
|
||||||
$rank = ['operational' => 0, 'unknown' => 1, 'degraded' => 2, 'down' => 3];
|
|
||||||
|
|
||||||
foreach ($components as $i => $component) {
|
|
||||||
foreach ($ongoing as $incident) {
|
|
||||||
if (! in_array($component['key'], $incident->components ?? [], true)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Maintenance is announced work, not a fault. It shows on the
|
|
||||||
// component without claiming anything is broken.
|
|
||||||
$state = $incident->impact === Incident::IMPACT_MAINTENANCE ? 'degraded' : $incident->impact;
|
|
||||||
|
|
||||||
if (($rank[$state] ?? 0) > ($rank[$component['state']] ?? 0)) {
|
|
||||||
$components[$i]['state'] = $state;
|
|
||||||
$components[$i]['detail'] = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$components[$i]['incident'] = $incident;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $components;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The daily bars, one array per component, oldest first.
|
|
||||||
*
|
|
||||||
* Every day in the window appears, including the ones nobody measured —
|
|
||||||
* they come back as null and are drawn as a gap. Filling them in from the
|
|
||||||
* days around them would be inventing the only number on this page a reader
|
|
||||||
* can check against their own memory.
|
|
||||||
*
|
|
||||||
* @return array<string, array{days: array<int, array<string, mixed>>, uptime: float|null}>
|
|
||||||
*/
|
|
||||||
private function history(): array
|
|
||||||
{
|
|
||||||
$from = Carbon::now()->local()->startOfDay()->subDays(self::HISTORY_DAYS - 1);
|
|
||||||
|
|
||||||
$rows = StatusDay::query()
|
|
||||||
->where('day', '>=', $from->toDateString())
|
|
||||||
->get()
|
|
||||||
->groupBy('component');
|
|
||||||
|
|
||||||
$history = [];
|
|
||||||
|
|
||||||
foreach (ServiceHealth::COMPONENTS as $component) {
|
|
||||||
$byDay = ($rows[$component] ?? collect())->keyBy(fn (StatusDay $d) => $d->day->toDateString());
|
|
||||||
|
|
||||||
$days = [];
|
|
||||||
$measured = 0;
|
|
||||||
$up = 0;
|
|
||||||
|
|
||||||
for ($i = 0; $i < self::HISTORY_DAYS; $i++) {
|
|
||||||
$date = $from->copy()->addDays($i);
|
|
||||||
$row = $byDay[$date->toDateString()] ?? null;
|
|
||||||
|
|
||||||
$days[] = [
|
|
||||||
'date' => $date,
|
|
||||||
'state' => $row?->worst(),
|
|
||||||
'uptime' => $row?->uptimePercent(),
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($row !== null) {
|
|
||||||
$measured += $row->samples - $row->unknown;
|
|
||||||
$up += $row->operational + $row->degraded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$history[$component] = [
|
|
||||||
'days' => $days,
|
|
||||||
// Over the whole window, not the mean of the daily figures: a
|
|
||||||
// day with four samples must not weigh the same as a day with
|
|
||||||
// three hundred.
|
|
||||||
'uptime' => $measured > 0 ? round($up / $measured * 100, 2) : null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $history;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolved incidents, newest first, grouped by the day they started.
|
|
||||||
*
|
|
||||||
* @return Collection<string, Collection<int, Incident>>
|
|
||||||
*/
|
|
||||||
private function past(): Collection
|
|
||||||
{
|
|
||||||
return Incident::query()
|
|
||||||
->published()
|
|
||||||
->whereNotNull('resolved_at')
|
|
||||||
->where('started_at', '>=', Carbon::now()->subDays(self::INCIDENT_DAYS))
|
|
||||||
->with('updates')
|
|
||||||
->orderByDesc('started_at')
|
|
||||||
->get()
|
|
||||||
->groupBy(fn (Incident $incident) => $incident->started_at->local()->toDateString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Announced work that has not finished yet.
|
|
||||||
*
|
|
||||||
* Taken from the maintenance windows the console already keeps rather than
|
|
||||||
* from a second list somebody has to remember to fill in. Published ones
|
|
||||||
* only: a draft is a plan, not an announcement.
|
|
||||||
*
|
|
||||||
* @return Collection<int, MaintenanceWindow>
|
|
||||||
*/
|
|
||||||
private function maintenance(): Collection
|
|
||||||
{
|
|
||||||
return MaintenanceWindow::query()
|
|
||||||
->whereNotNull('published_at')
|
|
||||||
->whereNull('cancelled_at')
|
|
||||||
->where('state', '!=', 'draft')
|
|
||||||
->where('ends_at', '>=', Carbon::now())
|
|
||||||
->orderBy('starts_at')
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,82 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
|
||||||
|
|
||||||
use App\Models\PlanFamily;
|
|
||||||
use App\Services\Billing\PlanCatalogue;
|
|
||||||
use LivewireUI\Modal\ModalComponent;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The marketing presentation of one plan line: who it is for, the sentence
|
|
||||||
* under its name on the price sheet, and whether it carries the
|
|
||||||
* "recommended" mark.
|
|
||||||
*
|
|
||||||
* These were a hardcoded array in LandingController, keyed on exactly four
|
|
||||||
* plan keys — a plan created under any other key rendered on the public page
|
|
||||||
* with no copy at all. They live on the family, not a version: a version is
|
|
||||||
* what gets published and superseded, but "who this is for" and "which one
|
|
||||||
* plan we point a visitor towards" are a stance about the product line, not a
|
|
||||||
* capability that changes with a price.
|
|
||||||
*/
|
|
||||||
class EditPlanFamily extends ModalComponent
|
|
||||||
{
|
|
||||||
public string $uuid = '';
|
|
||||||
|
|
||||||
public string $audience = '';
|
|
||||||
|
|
||||||
public string $note = '';
|
|
||||||
|
|
||||||
public bool $recommended = false;
|
|
||||||
|
|
||||||
public function mount(string $uuid): void
|
|
||||||
{
|
|
||||||
$this->authorize('plans.manage'); // modals bypass the route middleware
|
|
||||||
|
|
||||||
$family = PlanFamily::query()->where('uuid', $uuid)->firstOrFail();
|
|
||||||
$this->uuid = $uuid;
|
|
||||||
$this->audience = (string) $family->audience;
|
|
||||||
$this->note = (string) $family->note;
|
|
||||||
$this->recommended = (bool) $family->is_recommended;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function save()
|
|
||||||
{
|
|
||||||
$this->authorize('plans.manage');
|
|
||||||
|
|
||||||
$data = $this->validate([
|
|
||||||
'audience' => 'nullable|string|max:255',
|
|
||||||
'note' => 'nullable|string|max:500',
|
|
||||||
'recommended' => 'boolean',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$family = PlanFamily::query()->where('uuid', $this->uuid)->firstOrFail();
|
|
||||||
|
|
||||||
PlanFamily::query()->whereKey($family->getKey())->update([
|
|
||||||
'audience' => $data['audience'] !== '' ? $data['audience'] : null,
|
|
||||||
'note' => $data['note'] !== '' ? $data['note'] : null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// The singular invariant — only one family recommended at a time —
|
|
||||||
// lives in the catalogue service, not here: two operators recommending
|
|
||||||
// two different plans at the same moment must not both win.
|
|
||||||
if ($data['recommended']) {
|
|
||||||
app(PlanCatalogue::class)->recommend($family);
|
|
||||||
} elseif ($family->is_recommended) {
|
|
||||||
PlanFamily::query()->whereKey($family->getKey())->update(['is_recommended' => false]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->dispatch('notify', message: __('plans.marketing_saved'));
|
|
||||||
|
|
||||||
return $this->redirectRoute('admin.plans', navigate: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.admin.edit-plan-family', [
|
|
||||||
// Read again rather than from the mounted properties: the header
|
|
||||||
// shows the plan's name, and that can change under an operator's
|
|
||||||
// own hands while a second tab has this modal open.
|
|
||||||
'family' => PlanFamily::query()->where('uuid', $this->uuid)->firstOrFail(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,162 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
|
||||||
|
|
||||||
use App\Models\Incident;
|
|
||||||
use App\Models\IncidentUpdate;
|
|
||||||
use App\Services\Status\ServiceHealth;
|
|
||||||
use App\Support\LocalTime;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Livewire\Attributes\Layout;
|
|
||||||
use Livewire\Attributes\Validate;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writing down a disruption while it is happening.
|
|
||||||
*
|
|
||||||
* The public page can only be as good as what somebody types here, so the form
|
|
||||||
* is deliberately short: a title, what it touches, how bad it is, and the first
|
|
||||||
* sentence. Everything else is a further update, which is also the only way to
|
|
||||||
* change what the record says — an update is a statement made at a time, and
|
|
||||||
* editing it afterwards is precisely what this record exists to prevent.
|
|
||||||
*
|
|
||||||
* Nothing is public until it is published. An operator two minutes into an
|
|
||||||
* outage should be able to write carefully rather than publish a sentence they
|
|
||||||
* then have to correct in front of everybody.
|
|
||||||
*/
|
|
||||||
#[Layout('layouts.admin')]
|
|
||||||
class Incidents extends Component
|
|
||||||
{
|
|
||||||
#[Validate('required|string|max:160')]
|
|
||||||
public string $title = '';
|
|
||||||
|
|
||||||
#[Validate('required|in:degraded,down,maintenance')]
|
|
||||||
public string $impact = Incident::IMPACT_DEGRADED;
|
|
||||||
|
|
||||||
/** @var array<int, string> */
|
|
||||||
#[Validate('required|array|min:1')]
|
|
||||||
public array $components = [];
|
|
||||||
|
|
||||||
#[Validate('required|date')]
|
|
||||||
public string $startedAt = '';
|
|
||||||
|
|
||||||
#[Validate('required|string|max:2000')]
|
|
||||||
public string $firstUpdate = '';
|
|
||||||
|
|
||||||
/** The incident an update is being written for, by uuid. */
|
|
||||||
public ?string $updatingUuid = null;
|
|
||||||
|
|
||||||
#[Validate('nullable|in:investigating,identified,monitoring,resolved')]
|
|
||||||
public string $updateStage = 'identified';
|
|
||||||
|
|
||||||
#[Validate('nullable|string|max:2000')]
|
|
||||||
public string $updateBody = '';
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
// Now, in the operator's own time — they are reporting something that
|
|
||||||
// is happening, not filing a historical record (R19).
|
|
||||||
$this->startedAt = LocalTime::toField(now());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(): void
|
|
||||||
{
|
|
||||||
$this->authorize('maintenance.manage');
|
|
||||||
|
|
||||||
$this->validateOnly('title');
|
|
||||||
$this->validateOnly('impact');
|
|
||||||
$this->validateOnly('components');
|
|
||||||
$this->validateOnly('startedAt');
|
|
||||||
$this->validateOnly('firstUpdate');
|
|
||||||
|
|
||||||
// One transaction: an incident with no first update is a headline with
|
|
||||||
// nothing under it, and that is what a reader would see if the second
|
|
||||||
// insert failed.
|
|
||||||
DB::transaction(function () {
|
|
||||||
$incident = Incident::query()->create([
|
|
||||||
'title' => $this->title,
|
|
||||||
'impact' => $this->impact,
|
|
||||||
'components' => array_values(array_intersect(ServiceHealth::COMPONENTS, $this->components)),
|
|
||||||
'started_at' => LocalTime::fromField($this->startedAt),
|
|
||||||
'published_at' => now(),
|
|
||||||
'created_by' => Auth::guard('operator')->user()?->email,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$incident->updates()->create([
|
|
||||||
'status' => 'investigating',
|
|
||||||
'body' => $this->firstUpdate,
|
|
||||||
'created_by' => Auth::guard('operator')->user()?->email,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->reset(['title', 'impact', 'components', 'firstUpdate']);
|
|
||||||
$this->startedAt = LocalTime::toField(now());
|
|
||||||
$this->dispatch('notify', message: __('admin_incidents.created'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open the update box for one incident. */
|
|
||||||
public function startUpdate(string $uuid): void
|
|
||||||
{
|
|
||||||
$this->authorize('maintenance.manage');
|
|
||||||
$this->updatingUuid = $uuid;
|
|
||||||
$this->updateStage = 'identified';
|
|
||||||
$this->updateBody = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function cancelUpdate(): void
|
|
||||||
{
|
|
||||||
$this->reset(['updatingUuid', 'updateStage', 'updateBody']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function postUpdate(): void
|
|
||||||
{
|
|
||||||
$this->authorize('maintenance.manage');
|
|
||||||
|
|
||||||
$this->validate([
|
|
||||||
'updateStage' => 'required|in:investigating,identified,monitoring,resolved',
|
|
||||||
'updateBody' => 'required|string|max:2000',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$incident = Incident::query()->where('uuid', $this->updatingUuid)->firstOrFail();
|
|
||||||
|
|
||||||
DB::transaction(function () use ($incident) {
|
|
||||||
$incident->updates()->create([
|
|
||||||
'status' => $this->updateStage,
|
|
||||||
'body' => $this->updateBody,
|
|
||||||
'created_by' => Auth::guard('operator')->user()?->email,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// "Resolved" is the update AND the closing of the incident. Two
|
|
||||||
// separate actions is how a status page ends up with a resolved
|
|
||||||
// note under an incident that still reports as ongoing.
|
|
||||||
if ($this->updateStage === 'resolved' && ! $incident->isResolved()) {
|
|
||||||
$incident->forceFill(['resolved_at' => now()])->save();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->cancelUpdate();
|
|
||||||
$this->dispatch('notify', message: __('admin_incidents.update_posted'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<int, string> */
|
|
||||||
public function componentOptions(): array
|
|
||||||
{
|
|
||||||
return ServiceHealth::COMPONENTS;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$this->authorize('maintenance.manage');
|
|
||||||
|
|
||||||
return view('livewire.admin.incidents', [
|
|
||||||
'ongoing' => Incident::query()->ongoing()->with('updates')->orderByDesc('started_at')->get(),
|
|
||||||
'resolved' => Incident::query()
|
|
||||||
->whereNotNull('resolved_at')
|
|
||||||
->with('updates')
|
|
||||||
->orderByDesc('started_at')
|
|
||||||
->limit(25)
|
|
||||||
->get(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,117 +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,
|
|
||||||
'is_recommended' => $family->is_recommended,
|
|
||||||
'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,498 +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'));
|
|
||||||
|
|
||||||
// Opens the maintenance overlay on THIS click rather than on the
|
|
||||||
// watcher's next poll. Without it there were visibly two screens: this
|
|
||||||
// component re-rendered at once and grew a little "läuft gerade" panel
|
|
||||||
// inside the settings card, and up to three seconds later the overlay
|
|
||||||
// covered the page and said the same thing again. Reported as "first a
|
|
||||||
// small window, then the big one".
|
|
||||||
//
|
|
||||||
// Dispatched only once a run is actually pending — an overlay that
|
|
||||||
// covers the console because a button was pressed, over nothing, is
|
|
||||||
// worse than a slow one.
|
|
||||||
if ($accepted) {
|
|
||||||
$this->dispatch('update-started');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue